diff --git a/.github/instructions/sidebar-node-logic.instructions.md b/.github/instructions/sidebar-node-logic.instructions.md new file mode 100644 index 000000000..06543d92c --- /dev/null +++ b/.github/instructions/sidebar-node-logic.instructions.md @@ -0,0 +1,12 @@ +--- +applyTo: 'scripts/**/*.js' +--- +- In this program, a dataset with nodes and edges is visualized in different graphs and lists. These view modes are selectable in tabs. +- Nodes are parametrized with meta data including program ID, island number, generation nunmber, parent ID (which is used to determine the edge connections), a metric dataset with flexible keys, a code string, a dict with prompts and more. All data except program ID are optional. +- A sidebar shows detailed node information. Its format is the same across all view modes. +- The sidebar in this program is designed to show up dynamically when a node is selected in one of the graphs or lists. It appears on hover of the node and hides when the node is not hovered anymore. +- A single node can be selected to turn it "sticky". When a node is sticky, its information remains visible in the sidebar and the sidebar remains open until the user clicks in the background. Hovering another node will not change the sidebar content if a node is already sticky. +- The selected node is highlighted with a red border and synchronized across all graphs and lists. I.e., clicking a node in a list will also highlight it in the graphs. + +- A select box #highlight-select configures a filter logic that allows to highlight multiple nodes. Nodes are highlighted with a blue shadow in the graphs and lists. +- A select box #metric-select shows the available metrics (determined dynamically from the dataset), and the selected metric may be used in the graph creation, filter and sorting logic. \ No newline at end of file diff --git a/.github/workflows/python-lint.yml b/.github/workflows/python-lint.yml index 27e4225b6..29e0fcb29 100644 --- a/.github/workflows/python-lint.yml +++ b/.github/workflows/python-lint.yml @@ -13,5 +13,5 @@ jobs: - uses: psf/black@stable with: options: "--check --verbose" - src: "./openevolve ./tests ./examples" + src: "./openevolve ./tests ./examples ./scripts" use_pyproject: true \ No newline at end of file diff --git a/Makefile b/Makefile index 172611dd4..7ac6903dd 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ install: venv # Run Black code formatting .PHONY: lint lint: venv - $(PYTHON) -m black openevolve examples tests + $(PYTHON) -m black openevolve examples tests scripts # Run tests using the virtual environment .PHONY: test diff --git a/README.md b/README.md index db8db4612..daa70e0ca 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,20 @@ diff -u checkpoints/checkpoint_10/best_program.py checkpoints/checkpoint_20/best # Compare metrics cat checkpoints/checkpoint_*/best_program_info.json | grep -A 10 metrics ``` + +### Visualizing the evolution tree + +The script in `scripts/visualize.py` allows you to visualize the evolution tree and display it in your webbrowser. The script watches live for the newest checkpoint directory in the examples/ folder structure and updates the graph. Alternatively, you can also provide a specific checkpoint folder with the `--path` parameter. + +```bash +# Install requirements +pip install -r scripts/requirements.txt + +# Start the visualization web server +python scripts/visualizer.py +``` +![OpenEvolve Visualizer](openevolve-visualizer.png) + ### Docker You can also install and execute via Docker: diff --git a/openevolve-visualizer.png b/openevolve-visualizer.png new file mode 100644 index 000000000..5ff88edcf Binary files /dev/null and b/openevolve-visualizer.png differ diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 000000000..8ab6294c6 --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1 @@ +flask \ No newline at end of file diff --git a/scripts/static/css/main.css b/scripts/static/css/main.css new file mode 100644 index 000000000..b362b395c --- /dev/null +++ b/scripts/static/css/main.css @@ -0,0 +1,991 @@ +:root { + --toolbar-bg: #fff; + --sidebar-bg: #fff; + --text-color: #222; + --node-default: #fff; + --node-stroke: #fff; + --sidebar-shadow: -2px 0 24px #aaa; + --toolbar-shadow: 0 4px 16px #aaa; + --main-bg: #f7f7f7; + --tab-bg: #eee; + --tab-active-bg: #fff; + --tab-border: #ddd; + --tab-active-border: #fff; + --select-bg: #fff; + --select-color: #222; + --select-border: #ccc; + --toolbar-height: 3.5em; +} +[data-theme="dark"] { + --toolbar-bg: #282a2b; + --sidebar-bg: #23272a; + --text-color: #e6eaf3; + --node-default: #23272a; + --node-stroke: #3b5ca8; + --sidebar-shadow: -8px 0 24px #1e3a8ccc; + --toolbar-shadow: 0 4px 16px #1e3a8ccc; + --main-bg: #181a1b; + --tab-bg: #22304a; + --tab-active-bg: #1e2a3a; + --tab-border: #3b5ca8; + --tab-active-border: #3b5ca8; + --select-bg: #22304a; + --select-color: #e6eaf3; + --select-border: #3b5ca8; + --toolbar-height: 3.5em; +} +html, body { + height: 100%; + margin: 0; + padding: 0 2em; +} +body { + font-family: Arial, sans-serif; + background: var(--main-bg); + color: var(--text-color); + height: 100vh; + width: 100vw; +} +h1 span { font-size: 0.5em; color: #666; } +#toolbar { + display: flex; + align-items: center; + flex-direction: row; + gap: 0.5em; + padding: 0.7em 1.5em 0.7em 1.5em; + background: var(--toolbar-bg, #f8f9fa); + border-bottom: 1.5px solid #e0e0e0; + z-index: 10; + position: fixed; + top: 0; + left: 0; + right: 0; + height: 3.5em; + box-sizing: border-box; + box-shadow: 0 4px 16px #aaa; +} +#toolbar > .toolbar-spacer { + flex: 1 1 auto; + min-width: 1px; +} +#toolbar > .tabs, +#toolbar > label.toolbar-label, +#toolbar > select, +#toolbar > .toolbar-darkmode { + margin-left: 0; + margin-right: 0; +} +[data-theme="dark"] #toolbar { + background: #1a222c; + border-bottom: 1.5px solid #22334a; + box-shadow: 0 2px 12px 0 #00b4d8cc; +} +#toolbar > label.toolbar-label { + margin-left: 2em; + margin-bottom: 0; + margin-right: 0.2em; + font-weight: 500; + font-size: 1em; + color: var(--toolbar-label, #333); + white-space: nowrap; + transition: color 0.2s; +} +[data-theme="dark"] #toolbar > label.toolbar-label { + color: #e0e6ef; +} +#toolbar > select { + font-size: 1em; + margin-left: 0.5em; + margin-right: 1em; + min-width: 8.5em; + max-width: 14em; + padding: 0.2em 0.7em; + border-radius: 6px; + border: 1px solid #bbb; + background: var(--toolbar-select-bg, #fff); + color: var(--toolbar-select-color, #222); + vertical-align: middle; +} +#toolbar > .tabs { + margin-left: 1.5em; + margin-right: 1em; + display: flex; + align-items: center; + gap: 0.5em; +} +#toolbar > div[style*="margin-left:auto"] { + margin-left: auto !important; +} +@media (max-width: 900px) { + #toolbar { + flex-wrap: wrap; + height: auto; + padding: 0.7em 0.5em 0.7em 0.5em; + } + #toolbar > label.toolbar-label, #toolbar > select { + margin-top: 0.5em; + margin-bottom: 0.5em; + } +} +#sidebar { + position: fixed; + top: var(--toolbar-height); + right: 0; + width: 400px; + max-width: 90vw; + height: calc(100vh - var(--toolbar-height)); + background: var(--sidebar-bg); + box-shadow: var(--sidebar-shadow); + z-index: 90; + transform: translateX(100%); + transition: transform 0.2s; + overflow-y: auto; + padding: 1.5em 1.5em 1em 1.5em; +} +.tabs { + display: flex; + gap: 1em; + background: none; +} +.tab { + padding: 0.3em 1.2em; + border-radius: 6px 6px 0 0; + background: var(--tab-bg); + cursor: pointer; + font-weight: 500; + border: 1px solid var(--tab-border); + border-bottom: none; + color: var(--text-color); +} +.tab.active { + background: var(--tab-active-bg); + border-bottom: 1px solid var(--tab-active-border); + color: var(--text-color); +} +.toolbar-label { + font-size: 1em; + margin-left: 2em; + color: var(--text-color); +} +#highlight-select { + font-size: 1em; + margin-left: 0.5em; +} +#graph { + width: 100vw; + height: calc(100vh - var(--toolbar-height)); + min-height: 300px; + position: relative; + overflow: hidden; +} +#graph svg { + width: 100%; + height: 100%; + display: block; +} +#view-performance { + width: 100vw; + height: calc(100vh - var(--toolbar-height)); + min-height: 300px; + position: relative; + overflow: hidden; + padding: 0; + margin: 0; + display: block; +} +#performance-graph { + width: 100%; + height: 100%; + display: block; +} +.node circle { stroke: var(--node-stroke); stroke-width: 2px; } +.node text { pointer-events: none; font-size: 12px; } +.link { stroke: #999; stroke-opacity: 0.6; } +.tooltip { + position: absolute; + text-align: left; + width: 400px; + max-width: 90vw; + max-height: 60vh; + overflow: auto; + padding: 10px; + font: 12px sans-serif; + background: #fff; + border: 1px solid #aaa; + border-radius: 8px; + pointer-events: none; + box-shadow: 2px 2px 8px #aaa; + z-index: 10; +} +pre { + background: #f0f0f0; + color: #222; + padding: 6px; + border-radius: 4px; + max-height: 200px; + overflow: auto; + white-space: pre; + font-size: 1em; + line-height: 1.4; + font-family: 'Fira Mono', 'Consolas', 'Menlo', 'Monaco', monospace; +} +[data-theme="dark"] pre { + background: #1e2633; + color: #e6eaf3; + box-shadow: 0 2px 8px #1e3a8c44; + border: 1px solid #3b5ca8; +} +[data-theme="dark"] #sidebar-content pre, +[data-theme="dark"] .sidebar-tab-content pre { + color: #111 !important; + background: #f7f7f7 !important; +} +#sidebar-content { + display: block; + height: auto; + min-height: 0; + margin-bottom: 2.5em; +} +#sidebar-content pre { + display: block; + flex: 1 1 auto; + min-height: 0; + max-height: calc(100vh - 10em); + overflow: auto; + margin-bottom: 1.5em; + box-sizing: border-box; + white-space: pre; + word-break: normal; +} +.sidebar-code-pre { + display: block; + flex: 1 1 auto; + min-height: 0; + max-height: calc(100vh - 12em); + overflow: auto; + margin-bottom: 2.5em; + box-sizing: border-box; + background: #f7f7f7; + padding: 0.7em 1em; + border-radius: 6px; + font-family: 'Fira Mono', 'Consolas', 'Menlo', 'Monaco', monospace; + font-size: 1em; + line-height: 1.4; + white-space: pre; + word-break: normal; +} +[data-theme="dark"] .sidebar-code-pre { + background: #f7f7f7 !important; + color: #111 !important; +} +.sidebar-pre { + white-space: pre-wrap !important; + overflow-wrap: anywhere !important; + word-break: break-word !important; + max-height: 180px; + overflow: auto; + background: #f7f7f7; + padding: 0.7em 1em; + border-radius: 6px; + font-family: 'Fira Mono', 'Consolas', 'Menlo', 'Monaco', monospace; + font-size: 1em; + line-height: 1.4; + margin-bottom: 0.7em; +} +[data-theme="dark"] .sidebar-pre { + background: #f7f7f7 !important; + color: #111 !important; +} +select { + background: var(--select-bg); + color: var(--select-color); + border: 1px solid var(--select-border); + border-radius: 4px; + padding: 0.2em 0.5em; +} +#darkmode-toggle { + accent-color: #3b5ca8; +} +a { + color: #1a3fa6; + text-decoration: underline; + transition: color 0.2s; +} +a:visited { + color: #5a3fa6; +} +a:hover { + color: #0d2a6c; +} +[data-theme="dark"] a { + color: #6ea8ff; +} +[data-theme="dark"] a:visited { + color: #b0bfff; +} +[data-theme="dark"] a:hover { + color: #3b5ca8; +} +.node-selected { + stroke: red !important; + stroke-width: 3px !important; + transition: stroke 0.2s; + z-index: 10; +} +[data-theme="dark"] .node-selected { + stroke: red !important; + stroke-width: 3px !important; + z-index: 10; +} +.node-selected.node-hovered, .node-hovered.node-selected { + stroke: red !important; + stroke-width: 3px !important; +} +.node-selected.node-highlighted, .node-highlighted.node-selected { + stroke: red !important; + stroke-width: 3px !important; +} + +.node-hovered { + stroke: #FFD600; + stroke-width: 4px; + transition: stroke 0.1s; + z-index: 9; +} +[data-theme="dark"] .node-hovered { + stroke: #FFD600; + stroke-width: 4px; + z-index: 9; +} + +.node-highlighted { + stroke: #FFD600; + stroke-width: 4px; + filter: drop-shadow(0 0 10px #FFD60088); + transition: stroke 0.2s, filter 0.2s; +} +[data-theme="dark"] .node-highlighted { + stroke: #00b4d8; + stroke-width: 4px; + filter: drop-shadow(0 0 12px #00b4d8cc); +} + +.node-highlighted { + filter: drop-shadow(0 0 8px #2196f3) drop-shadow(0 0 16px #2196f3); + stroke: #2196f3; + stroke-width: 4px; + transition: filter 0.2s, stroke 0.2s; +} +[data-theme="dark"] .node-highlighted { + filter: drop-shadow(0 0 10px #00b4d8) drop-shadow(0 0 20px #00b4d8); + stroke: #00b4d8; + stroke-width: 4px; +} + +.node-locator-highlight { + filter: drop-shadow(0 0 24px 16px #FFD600) !important; + transition: filter 0.7s cubic-bezier(0.4,0,0.2,1); + z-index: 10; +} + +@media (max-width: 1200px) { + #toolbar { + flex-wrap: wrap; + gap: 1em; + padding-right: 1em; + } +} + +.toggle-switch { + position: relative; + display: inline-block; + width: 48px; + height: 28px; + vertical-align: middle; +} +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; left: 0; right: 0; bottom: 0; + background: #ccc; + border-radius: 28px; + transition: background 0.2s; +} +.toggle-slider:before { + position: absolute; + content: ""; + height: 22px; + width: 22px; + left: 3px; + bottom: 3px; + background: #fff; + border-radius: 50%; + transition: transform 0.2s; + box-shadow: 0 2px 6px #0002; +} +.toggle-switch input:checked + .toggle-slider { + background: #3b5ca8; +} +.toggle-switch input:checked + .toggle-slider:before { + transform: translateX(20px); + background: #e6eaf3; +} +[data-theme="dark"] .toggle-slider { + background: #444; +} +[data-theme="dark"] .toggle-switch input:checked + .toggle-slider { + background: #6ea8ff; +} +[data-theme="dark"] .toggle-switch input:checked + .toggle-slider:before { + background: #23272a; +} + +#node-list-container { + display: flex; + flex-direction: column; + gap: 0.5em; + width: 100%; +} +.node-list-item { + display: flex; + flex-direction: row; + align-items: center; + gap: 32px; + padding: 12px 8px; + margin: 0 0 10px 0; + border-radius: 8px; + border: 1.5px solid #4442; + box-shadow: none; + background: none; + position: relative; + z-index: 1; + user-select: text; + font-size: 1em; + min-height: 80px; + padding-left: 5em; +} +[data-theme="dark"] .node-list-item { + color: #fff !important; + background: none !important; +} +.node-list-item.selected { + border: 2.5px solid red !important; + box-shadow: 0 0 0 2px #2196f344; + z-index: 3; +} +.node-list-item.highlighted { + box-shadow: 0 0 0 2px #2196f3; + z-index: 2; +} +.node-list-item.selected.highlighted, .node-list-item.highlighted.selected { + border: 2.5px solid red !important; + box-shadow: 0 0 0 2px #2196f3, 0 0 0 3px red; + z-index: 4; +} +[data-theme="dark"] .node-list-item.selected { + border: 2.5px solid red !important; + box-shadow: 0 0 0 2px #00b4d8cc; +} +[data-theme="dark"] .node-list-item.highlighted { + box-shadow: 0 0 0 2px #00b4d8; +} +[data-theme="dark"] .node-list-item.selected.highlighted { + border: 2.5px solid red !important; + box-shadow: 0 0 0 2px #00b4d8, 0 0 0 3px red; +} + +.node-list-item.node-locator-highlight { + box-shadow: 0 0 0 4px #FFD600, 0 0 16px 8px #FFD600; + transition: box-shadow 0.7s cubic-bezier(0.4,0,0.2,1); + z-index: 10; +} + +.node-info-block { + display: flex; + flex-direction: row; + gap: 2em; + margin-bottom: 0.3em; + flex-wrap: wrap; +} +.metrics-block-outer { + flex: 1 1 0; + margin-left: 32px; + margin-top: 0.5em; + display: flex; + flex-direction: column; + gap: 0.5em; + margin: 1em 0; +} +.metrics-block { + display: grid; + grid-template-columns: minmax(70px,auto) minmax(70px,auto) minmax(80px,auto); + gap: 0.5em 1.2em; + width: 100%; +} +.metric-row { + display: contents; +} +.metric-label { + min-width: 70px; + font-weight: 500; + color: #444; + text-align: left; + grid-column: 1; +} +[data-theme="dark"] .metric-label { + color: #e6eaf3; +} +.metric-value { + min-width: 70px; + font-family: 'Fira Mono', 'Consolas', 'Menlo', 'Monaco', monospace; + text-align: left; + grid-column: 2; +} +.metric-bar { + position: relative; + display: inline-block; + width: 80px; + height: 12px; + vertical-align: middle; + background: #e6eaf3; + border-radius: 3px; + overflow: hidden; + border: 2px solid #2196f3; + margin-left: 0; + text-align: left; + grid-column: 3; +} +.metric-bar-min, .metric-bar-max { + color: #bbb; + font-size: 0.85em; + position: absolute; + top: -1.2em; + pointer-events: none; +} +.metric-bar-min { left: 0; } +.metric-bar-max { right: 0; } +.metric-bar-fill { + display: block; + height: 12px; + background: linear-gradient(90deg,#2196f3,#3b5ca8); + border-radius: 3px; + transition: width 0.2s; + position: relative; + width: 80px; +} + +.node-select-area { + position: absolute; + right: 0; top: 0; bottom: 0; + width: 32px; + cursor: pointer; + z-index: 10; + background: transparent; +} + +.node-list-item > div { + flex: 1 1 0; + min-width: 0; + padding-right: 1.5em; + display: flex; + flex-direction: column; + gap: 0.2em; +} + +#view-list { + display: block; + padding-top: 4.5em !important; +} +#view-list > div:first-child { + margin-bottom: 1em; +} + +.node-list-header { + display: flex; + flex-direction: row; + font-weight: bold; + color: #888; + padding: 0.2em 1.2em 0.2em 1.2em; + border-bottom: 1.5px solid #e0e0e0; + margin-bottom: 0.2em; +} +[data-theme="dark"] .node-list-header { + color: #b0b8c0; + border-color: #2a3a4a; +} + +#list-search { + background: var(--sidebar-bg); + color: var(--text-color); + border: 1px solid var(--select-border); +} +#list-sort { + background: var(--sidebar-bg); + color: var(--text-color); + border: 1px solid var(--select-border); +} + +.sidebar-pre { + white-space: pre-wrap !important; + overflow-wrap: anywhere !important; + word-break: break-word !important; + max-height: 180px; + overflow: auto; + background: #f7f7f7; + padding: 0.7em 1em; + border-radius: 6px; + font-family: 'Fira Mono', 'Consolas', 'Menlo', 'Monaco', monospace; + font-size: 1em; + line-height: 1.4; + margin-bottom: 0.7em; +} +[data-theme="dark"] .sidebar-pre { + background: #f7f7f7 !important; + color: #111 !important; +} + +.fitness-bar { + width: 12px; + min-width: 12px; + max-width: 12px; + border-radius: 7px; + border: 2px solid orange; + left: 0; top: 0; bottom: 0; + align-self: stretch; + position: relative; + width: 28px; + height: 100%; + min-height: 80px; + margin: 2em 0 0 1em; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: flex-end; + background: #e6eaf3; + border-radius: 4px; + overflow: visible; +} +.fitness-bar-fill { + border-radius: 6px; + background: linear-gradient(180deg, #2196f3 0%, #3b5ca8 100%); + width: 100%; + position: absolute; + left: 0; right: 0; bottom: 0; + transition: height 0.2s; +} +.fitness-bar-max, .fitness-bar-min { + color: #bbb; + font-size: 0.85em; + position: absolute; + right: 0; + left: auto; + pointer-events: none; + text-align: right; +} +.fitness-bar-max { top: -1.2em; } +.fitness-bar-min { top: 100%; } +.fitness-bar-fill { + position: absolute; + left: 0; right: 0; bottom: 0; + width: 100%; + background: linear-gradient(180deg, #2196f3 0%, #3b5ca8 100%); + border-radius: 4px 4px 0 0; + transition: height 0.2s; +} + +.summary-block { + display: flex; + align-items: center; + gap: 0.7em; + min-width: 220px; + margin-right: 1.5em; +} +.summary-icon { + font-size: 1.5em; + margin-right: 0.2em; + vertical-align: middle; +} +.summary-label { + font-weight: 600; + color: #444; + margin-right: 0.2em; + font-size: 1.08em; +} +[data-theme="dark"] .summary-label { + color: #e6eaf3; +} +.summary-value { + font-family: 'Fira Mono', 'Consolas', 'Menlo', 'Monaco', monospace; + min-width: 90px; + text-align: right; + font-size: 1.13em; + margin-right: 0.7em; + color: #222; +} +[data-theme="dark"] .summary-value { + color: #e6eaf3; +} +.summary-bar-outer { + width: 100px; + height: 16px; + background: #e6eaf3; + border-radius: 8px; + overflow: hidden; + margin-right: 0.7em; + border: 2px solid #2196f3; + display: inline-block; + vertical-align: middle; +} +.summary-bar-inner { + height: 100%; + background: linear-gradient(90deg,#2196f3,#3b5ca8); + border-radius: 8px; + transition: width 0.2s; +} + +.list-summary-bar { + display: flex; + align-items: center; + gap: 1.2em; + padding: 0.7em 1.2em 0.7em 1.2em; + background: #f7f7fa; + border-radius: 8px; + margin-bottom: 1em; + font-size: 1.08em; + box-shadow: 0 2px 8px #e6eaf344; + flex-wrap: wrap; +} +[data-theme="dark"] .list-summary-bar { + background: #23272a; + color: #e6eaf3; + box-shadow: 0 2px 8px #1e3a8c44; +} +.summary-label { + font-weight: 500; + color: #444; + margin-right: 0.2em; +} +[data-theme="dark"] .summary-label { + color: #e6eaf3; +} +.summary-value { + font-family: 'Fira Mono', 'Consolas', 'Menlo', 'Monaco', monospace; + min-width: 70px; + text-align: right; + margin-right: 0.7em; +} +.summary-bar-outer { + width: 80px; + height: 12px; + background: #e6eaf3; + border-radius: 6px; + overflow: hidden; + margin-right: 1.2em; + border: 1.5px solid #2196f3; + display: inline-block; + vertical-align: middle; +} +.summary-bar-inner { + height: 100%; + background: linear-gradient(90deg,#2196f3,#3b5ca8); + border-radius: 6px; + transition: width 0.2s; +} + +#sidebar-tab-bar { + display: flex; + gap: 1em; + margin: 1em 0 0.5em 0; +} +.sidebar-tab { + cursor: pointer; + padding: 0.2em 1.2em; + border-radius: 6px 6px 0 0; + background: #eee; + font-weight: 500; + color: #222; + transition: background 0.2s, color 0.2s; +} +.sidebar-tab.active { + background: #fff; + color: #222; +} +[data-theme="dark"] .sidebar-tab { + background: #22304a; + color: #e6eaf3; +} +[data-theme="dark"] .sidebar-tab.active { + background: #23272a; + color: #e6eaf3; +} + +.performance-metric-row { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.35em; + margin-bottom: 1.2em; +} +.performance-metric-label { + font-weight: 500; + color: #444; + margin-bottom: 0.1em; +} +[data-theme="dark"] .performance-metric-label { + color: #e6eaf3; +} +.performance-metric-value { + font-family: 'Fira Mono', 'Consolas', 'Menlo', 'Monaco', monospace; + font-size: 1.13em; + margin-bottom: 0.2em; +} +.performance-metric-bar { + margin-top: 0.2em; + margin-bottom: 0.2em; + min-height: 18px; + position: relative; +} +.performance-metric-bar .metric-bar-min, +.performance-metric-bar .metric-bar-max { + top: -1.2em; + font-size: 0.85em; + color: #bbb; +} +.performance-metric-bar .metric-bar-max { + right: 0; + left: auto; +} +.performance-metric-bar .metric-bar-min { + left: 0; +} + +.node-list-item { + min-height: 80px; + align-items: stretch; + gap: 32px !important; +} + +.node-info-block { + flex: 0 0 170px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.5em; + margin-right: 32px; +} +.metrics-block-outer { + flex: 1 1 0; + margin-left: 32px; + margin-top: 0.5em; + display: flex; + flex-direction: column; + gap: 0.5em; + margin: 1em 0; +} +.metrics-block { + display: grid; + grid-template-columns: 0.3fr 0.3fr 1fr; + gap: 0.5em 1.2em; +} +.metric-row { + display: contents; +} +.metric-label { + min-width: 70px; + font-weight: 500; + color: #444; + text-align: left; + grid-column: 1; +} +[data-theme="dark"] .metric-label { + color: #e6eaf3; +} +.metric-value { + min-width: 70px; + font-family: 'Fira Mono', 'Consolas', 'Menlo', 'Monaco', monospace; + text-align: left; + grid-column: 2; +} +.metric-bar { + position: relative; + display: inline-block; + width: 80px; + height: 12px; + vertical-align: middle; + background: #e6eaf3; + border-radius: 3px; + overflow: hidden; + border: 2px solid #2196f3; + margin-left: 0; + text-align: left; + grid-column: 3; +} +.metric-bar-min, .metric-bar-max { + color: #bbb; + font-size: 0.85em; + position: absolute; + top: -1.2em; + pointer-events: none; +} +.metric-bar-min { left: 0; } +.metric-bar-max { right: 0; } +.metric-bar-fill { + display: block; + height: 12px; + background: linear-gradient(90deg,#2196f3,#3b5ca8); + border-radius: 3px; + transition: width 0.2s; + position: relative; + width: 80px; +} + +.node-info-table { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.2em 1.2em; + margin-bottom: 0.5em; +} +.node-info-row { + display: contents; +} +.node-info-label { + font-weight: 500; + color: #444; + text-align: left; + white-space: nowrap; +} +.node-info-value { + text-align: left; + color: #222; + word-break: break-all; +} +.selected-metric-block-table { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.2em 1.2em; + align-items: center; + margin-bottom: 1.5em; +} +.selected-metric-label { + font-weight: bold; + color: #444; + text-align: left; +} +.selected-metric-value { + font-weight: normal; + color: #222; + text-align: left; +} +[data-theme="dark"] .node-info-label, +[data-theme="dark"] .selected-metric-label { + color: #e6eaf3; +} +[data-theme="dark"] .node-info-value, +[data-theme="dark"] .selected-metric-value { + color: #e6eaf3; +} diff --git a/scripts/static/js/graph.js b/scripts/static/js/graph.js new file mode 100644 index 000000000..8042fc3e6 --- /dev/null +++ b/scripts/static/js/graph.js @@ -0,0 +1,403 @@ +import { getHighlightNodes, allNodeData, selectedProgramId, setSelectedProgramId, lastDataStr } from './main.js'; +import { width, height } from './state.js'; +import { openInNewTab, showSidebarContent, sidebarSticky, showSidebar, setSidebarSticky, hideSidebar } from './sidebar.js'; +import { renderNodeList, selectListNodeById } from './list.js'; + +export function scrollAndSelectNodeById(nodeId) { + // Helper to get edges from lastDataStr (as in main.js resize) + function getCurrentEdges() { + let edges = []; + if (typeof lastDataStr === 'string') { + try { + const parsed = JSON.parse(lastDataStr); + edges = parsed.edges || []; + } catch {} + } + return edges; + } + const container = document.getElementById('node-list-container'); + if (container) { + const rows = Array.from(container.children); + const target = rows.find(div => div.getAttribute('data-node-id') === nodeId); + if (target) { + target.scrollIntoView({behavior: 'smooth', block: 'center'}); + setSelectedProgramId(nodeId); + renderNodeList(allNodeData); + showSidebarContent(allNodeData.find(n => n.id == nodeId)); + showSidebar(); + setSidebarSticky(true); + selectProgram(selectedProgramId); + renderGraph({ nodes: allNodeData, edges: getCurrentEdges() }, { centerNodeId: nodeId }); + updateGraphNodeSelection(); + return true; + } + } + const node = allNodeData.find(n => n.id == nodeId); + if (node) { + setSelectedProgramId(nodeId); + showSidebarContent(node); + showSidebar(); + setSidebarSticky(true); + selectProgram(selectedProgramId); + renderGraph({ nodes: allNodeData, edges: getCurrentEdges() }, { centerNodeId: nodeId }); + updateGraphNodeSelection(); + return true; + } + return false; +} + +export function updateGraphNodeSelection() { + if (!g) return; + g.selectAll('circle') + .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); +} + +export function getNodeColor(d) { + if (d.island !== undefined) return d3.schemeCategory10[d.island % 10]; + return getComputedStyle(document.documentElement) + .getPropertyValue('--node-default').trim() || "#fff"; +} + +function getSelectedMetric() { + const metricSelect = document.getElementById('metric-select'); + return metricSelect ? metricSelect.value : 'overall_score'; +} + +export function getNodeRadius(d) { + let minScore = Infinity, maxScore = -Infinity; + let minR = 10, maxR = 32; + const metric = getSelectedMetric(); + + if (Array.isArray(allNodeData) && allNodeData.length > 0) { + allNodeData.forEach(n => { + if (n.metrics && typeof n.metrics[metric] === "number") { + if (n.metrics[metric] < minScore) minScore = n.metrics[metric]; + if (n.metrics[metric] > maxScore) maxScore = n.metrics[metric]; + } + }); + if (minScore === Infinity) minScore = 0; + if (maxScore === -Infinity) maxScore = 1; + } else { + minScore = 0; + maxScore = 1; + } + + let score = d.metrics && typeof d.metrics[metric] === "number" ? d.metrics[metric] : null; + if (score === null || isNaN(score)) { + return minR / 2; + } + if (maxScore === minScore) return (minR + maxR) / 2; + score = Math.max(minScore, Math.min(maxScore, score)); + return minR + (maxR - minR) * (score - minScore) / (maxScore - minScore); +} + +export function selectProgram(programId) { + const nodes = g.selectAll("circle"); + nodes.each(function(d) { + const nodeElem = d3.select(this); + if (d.id === programId) { + nodeElem.classed("node-selected", true); + } else { + nodeElem.classed("node-selected", false); + } + nodeElem.classed("node-hovered", false); + }); +} + +let svg = null; +let g = null; +let simulation = null; // Keep simulation alive +let zoomBehavior = null; // Ensure zoomBehavior is available for locator + +// Ensure window.g is always up to date for static export compatibility +Object.defineProperty(window, 'g', { + get: function() { return g; }, + set: function(val) { g = val; } +}); + +function ensureGraphSvg() { + // Get latest width/height from state.js + let svgEl = d3.select('#graph').select('svg'); + if (svgEl.empty()) { + svgEl = d3.select('#graph').append('svg') + .attr('width', width) + .attr('height', height) + .attr('id', 'graph-svg'); + } else { + svgEl.attr('width', width).attr('height', height); + } + let gEl = svgEl.select('g'); + if (gEl.empty()) { + gEl = svgEl.append('g'); + } + return { svg: svgEl, g: gEl }; +} + +function applyDragHandlersToAllNodes() { + if (!g) return; + g.selectAll('circle').each(function() { + d3.select(this).on('.drag', null); + d3.select(this).call(d3.drag() + .on('start', dragstarted) + .on('drag', dragged) + .on('end', dragended)); + }); +} + +function renderGraph(data, options = {}) { + const { svg: svgEl, g: gEl } = ensureGraphSvg(); + svg = svgEl; + g = gEl; + window.g = g; // Ensure global assignment for static export + if (!g) { + console.warn('D3 group (g) is null in renderGraph. Aborting render.'); + return; + } + // Preserve zoom/pan + let prevTransform = null; + if (!svg.empty()) { + const gZoom = svg.select('g'); + if (!gZoom.empty()) { + const transform = gZoom.attr('transform'); + if (transform) prevTransform = transform; + } + } + g.selectAll("*").remove(); + + // Keep simulation alive and update nodes/links + if (!simulation) { + simulation = d3.forceSimulation(data.nodes) + .force("link", d3.forceLink(data.edges).id(d => d.id).distance(80)) + .force("charge", d3.forceManyBody().strength(-200)) + .force("center", d3.forceCenter(width / 2, height / 2)); + } else { + simulation.nodes(data.nodes); + simulation.force("link").links(data.edges); + simulation.alpha(0.7).restart(); + } + + const link = g.append("g") + .attr("stroke", "#999") + .attr("stroke-opacity", 0.6) + .selectAll("line") + .data(data.edges) + .enter().append("line") + .attr("stroke-width", 2); + + const metric = getSelectedMetric(); + const highlightFilter = document.getElementById('highlight-select').value; + const highlightNodes = getHighlightNodes(data.nodes, highlightFilter, metric); + const highlightIds = new Set(highlightNodes.map(n => n.id)); + + const node = g.append("g") + .attr("stroke", getComputedStyle(document.documentElement).getPropertyValue('--node-stroke').trim() || "#fff") + .attr("stroke-width", 1.5) + .selectAll("circle") + .data(data.nodes) + .enter().append("circle") + .attr("r", d => getNodeRadius(d)) + .attr("fill", d => getNodeColor(d)) + .attr("class", d => [ + highlightIds.has(d.id) ? 'node-highlighted' : '', + selectedProgramId === d.id ? 'node-selected' : '' + ].join(' ').trim()) + .attr('stroke', d => selectedProgramId === d.id ? 'red' : (highlightIds.has(d.id) ? '#2196f3' : '#333')) + .attr('stroke-width', d => selectedProgramId === d.id ? 3 : 1.5) + .on("click", function(event, d) { + setSelectedProgramId(d.id); + setSidebarSticky(true); + selectListNodeById(d.id); // sync list selection + g.selectAll('circle') + .classed('node-hovered', false) + .classed('node-selected', false) + .classed('node-highlighted', nd => highlightIds.has(nd.id)) + .classed('node-selected', nd => selectedProgramId === nd.id); + d3.select(this).classed('node-selected', true); + showSidebarContent(d, false); + showSidebar(); + selectProgram(selectedProgramId); + event.stopPropagation(); + updateGraphNodeSelection(); // Ensure all nodes update selection border + }) + .on("dblclick", openInNewTab) + .on("mouseover", function(event, d) { + if (!sidebarSticky && (!selectedProgramId || selectedProgramId !== d.id)) { + showSidebarContent(d, true); + showSidebar(); + } + d3.select(this) + .classed('node-hovered', true) + .attr('stroke', '#FFD600').attr('stroke-width', 4); + }) + .on("mouseout", function(event, d) { + d3.select(this) + .classed('node-hovered', false) + .attr('stroke', selectedProgramId === d.id ? 'red' : (highlightIds.has(d.id) ? '#2196f3' : '#333')) + .attr('stroke-width', selectedProgramId === d.id ? 3 : 1.5); + if (!selectedProgramId) { + hideSidebar(); + } + }); + + node.append("title").text(d => d.id); + + simulation.on("tick", () => { + link + .attr("x1", d => d.source.x) + .attr("y1", d => d.source.y) + .attr("x2", d => d.target.x) + .attr("y2", d => d.target.y); + node + .attr("cx", d => d.x) + .attr("cy", d => d.y); + }); + + // Intelligent zoom/pan + const zoomBehavior = d3.zoom() + .scaleExtent([0.2, 10]) + .on('zoom', function(event) { + g.attr('transform', event.transform); + }); + svg.call(zoomBehavior); + if (prevTransform) { + g.attr('transform', prevTransform); + const t = d3.zoomTransform(g.node()); + svg.call(zoomBehavior.transform, t); + } else if (options.fitToNodes) { + setTimeout(() => { + try { + const allCircles = g.selectAll('circle').nodes(); + if (allCircles.length > 0) { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + allCircles.forEach(c => { + const bbox = c.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); + }); + const pad = 40; + 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); + const tx = (graphW - scale * (minX + maxX)) / 2; + const ty = (graphH - scale * (minY + maxY)) / 2; + const t = d3.zoomIdentity.translate(tx, ty).scale(scale); + svg.transition().duration(400).call(zoomBehavior.transform, t); + } + } catch {} + }, 0); + } else if (options.centerNodeId) { + setTimeout(() => { + try { + const node = g.selectAll('circle').filter(d => d.id == options.centerNodeId).node(); + if (node) { + const bbox = node.getBBox(); + const graphW = svg.attr('width'); + const graphH = svg.attr('height'); + const scale = Math.min(graphW / (bbox.width * 6), graphH / (bbox.height * 6), 1.5); + const tx = graphW/2 - scale * (bbox.x + bbox.width/2); + const ty = graphH/2 - scale * (bbox.y + bbox.height/2); + const t = d3.zoomIdentity.translate(tx, ty).scale(scale); + svg.transition().duration(400).call(zoomBehavior.transform, t); + } + } catch {} + }, 0); + } + + selectProgram(selectedProgramId); + applyDragHandlersToAllNodes(); + + svg.on("click", function(event) { + if (event.target === svg.node()) { + setSelectedProgramId(null); + setSidebarSticky(false); + hideSidebar(); + g.selectAll("circle") + .classed("node-selected", false) + .classed("node-hovered", false) + .attr("stroke", function(d) { return (highlightIds.has(d.id) ? '#2196f3' : '#333'); }) + .attr("stroke-width", 1.5); + selectListNodeById(null); + } + }); +} + +export function animateGraphNodeAttributes() { + if (!g) return; + const metric = getSelectedMetric(); + const filter = document.getElementById('highlight-select').value; + const highlightNodes = getHighlightNodes(allNodeData, filter, metric); + const highlightIds = new Set(highlightNodes.map(n => n.id)); + g.selectAll('circle') + .transition().duration(400) + .attr('r', d => getNodeRadius(d)) + .attr('fill', d => getNodeColor(d)) + .attr('stroke', d => selectedProgramId === d.id ? 'red' : (highlightIds.has(d.id) ? '#2196f3' : '#333')) + .attr('stroke-width', d => selectedProgramId === d.id ? 3 : 1.5) + .attr('opacity', 1) + .on('end', null) + .selection() + .each(function(d) { + d3.select(this) + .classed('node-highlighted', highlightIds.has(d.id)) + .classed('node-selected', selectedProgramId === d.id); + }); + setTimeout(applyDragHandlersToAllNodes, 420); +} + +export function centerAndHighlightNodeInGraph(nodeId) { + if (!g || !svg) return; + // Ensure zoomBehavior is available and is a function + if (!zoomBehavior || typeof zoomBehavior !== 'function') { + zoomBehavior = d3.zoom() + .scaleExtent([0.2, 10]) + .on('zoom', function(event) { + g.attr('transform', event.transform); + }); + svg.call(zoomBehavior); + } + const nodeSel = g.selectAll('circle').filter(d => d.id == nodeId); + if (!nodeSel.empty()) { + // Pan/zoom to node + const node = nodeSel.node(); + const bbox = node.getBBox(); + const graphW = svg.attr('width'); + const graphH = svg.attr('height'); + const scale = Math.min(graphW / (bbox.width * 6), graphH / (bbox.height * 6), 1.5); + const tx = graphW/2 - scale * (bbox.x + bbox.width/2); + const ty = graphH/2 - scale * (bbox.y + bbox.height/2); + const t = d3.zoomIdentity.translate(tx, ty).scale(scale); + // Use the correct D3 v7 API for programmatic zoom + svg.transition().duration(400).call(zoomBehavior.transform, t); + // Yellow shadow highlight + nodeSel.each(function() { + const el = d3.select(this); + el.classed('node-locator-highlight', true) + .style('filter', 'drop-shadow(0 0 16px 8px #FFD600)'); + el.transition().duration(350).style('filter', 'drop-shadow(0 0 24px 16px #FFD600)') + .transition().duration(650).style('filter', null) + .on('end', function() { el.classed('node-locator-highlight', false); }); + }); + } +} + +function dragstarted(event, d) { + if (!event.active && simulation) simulation.alphaTarget(0.3).restart(); // Keep simulation alive + d.fx = d.x; + d.fy = d.y; +} +function dragged(event, d) { + d.fx = event.x; + d.fy = event.y; +} +function dragended(event, d) { + if (!event.active && simulation) simulation.alphaTarget(0); + d.fx = null; + d.fy = null; +} + +export { renderGraph, g }; diff --git a/scripts/static/js/list.js b/scripts/static/js/list.js new file mode 100644 index 000000000..b08184866 --- /dev/null +++ b/scripts/static/js/list.js @@ -0,0 +1,203 @@ +import { allNodeData, archiveProgramIds, formatMetrics, renderMetricBar, getHighlightNodes, getSelectedMetric, setAllNodeData, selectedProgramId, setSelectedProgramId } from './main.js'; +import { showSidebar, setSidebarSticky, showSidebarContent } from './sidebar.js'; +import { selectProgram, scrollAndSelectNodeById } from './graph.js'; +import { selectPerformanceNodeById } from './performance.js'; + +export function renderNodeList(nodes) { + setAllNodeData(nodes); + const container = document.getElementById('node-list-container'); + if (!container) return; + const search = document.getElementById('list-search').value.trim().toLowerCase(); + const sort = document.getElementById('list-sort').value; + let filtered = nodes; + if (search) { + filtered = nodes.filter(n => (n.id + '').toLowerCase().includes(search)); + } + const metric = getSelectedMetric(); + if (sort === 'id') { + filtered = filtered.slice().sort((a, b) => (a.id + '').localeCompare(b.id + '')); + } else if (sort === 'generation') { + filtered = filtered.slice().sort((a, b) => (a.generation || 0) - (b.generation || 0)); + } else if (sort === 'island') { + filtered = filtered.slice().sort((a, b) => (a.island || 0) - (b.island || 0)); + } else if (sort === 'score') { + filtered = filtered.slice().sort((a, b) => { + const aScore = a.metrics && typeof a.metrics[metric] === 'number' ? a.metrics[metric] : -Infinity; + const bScore = b.metrics && typeof b.metrics[metric] === 'number' ? b.metrics[metric] : -Infinity; + return bScore - aScore; + }); + } + const highlightFilter = document.getElementById('highlight-select').value; + const highlightNodes = getHighlightNodes(nodes, highlightFilter, metric); + const highlightIds = new Set(highlightNodes.map(n => n.id)); + const allScores = nodes.map(n => (n.metrics && typeof n.metrics[metric] === 'number') ? n.metrics[metric] : null).filter(x => x !== null && !isNaN(x)); + const minScore = allScores.length ? Math.min(...allScores) : 0; + const maxScore = allScores.length ? Math.max(...allScores) : 1; + const topScore = allScores.length ? Math.max(...allScores) : 0; + const avgScore = allScores.length ? (allScores.reduce((a, b) => a + b, 0) / allScores.length) : 0; + + let summaryBar = document.getElementById('list-summary-bar'); + if (!summaryBar) { + summaryBar = document.createElement('div'); + summaryBar.id = 'list-summary-bar'; + summaryBar.className = 'list-summary-bar'; + container.parentElement.insertBefore(summaryBar, container); + } + summaryBar.innerHTML = ` +
+ 🏆 + Top score + ${topScore.toFixed(4)} + ${renderMetricBar(topScore, minScore, maxScore)} +
+
+ 📊 + Average + ${avgScore.toFixed(4)} + ${renderMetricBar(avgScore, minScore, maxScore)} +
+ `; + container.innerHTML = ''; + filtered.forEach(node => { + const row = document.createElement('div'); + row.className = 'node-list-item' + (selectedProgramId === node.id ? ' selected' : '') + (highlightIds.has(node.id) ? ' highlighted' : ''); + row.setAttribute('data-node-id', node.id); + row.tabIndex = 0; + let selectedMetricRow = ''; + if (node.metrics && metric in node.metrics) { + let val = (typeof node.metrics[metric] === 'number' && isFinite(node.metrics[metric])) ? node.metrics[metric].toFixed(4) : node.metrics[metric]; + let allVals = nodes.map(n => (n.metrics && typeof n.metrics[metric] === 'number') ? n.metrics[metric] : null).filter(x => x !== null && isFinite(x)); + let minV = allVals.length ? Math.min(...allVals) : 0; + let maxV = allVals.length ? Math.max(...allVals) : 1; + selectedMetricRow = `
+ ${metric}: + + ${val} + ${renderMetricBar(node.metrics[metric], minV, maxV)} + +
`; + } + const infoBlock = document.createElement('div'); + infoBlock.className = 'node-info-block'; + infoBlock.innerHTML = ` +
+ ${selectedMetricRow} +
ID:${node.id}
+
Gen:${node.generation ?? ''}
+
Island:${node.island ?? ''}
+
Parent:${node.parent_id ?? 'None'}
+
+ `; + let metricsHtml = '
'; + if (node.metrics) { + Object.entries(node.metrics).forEach(([k, v]) => { + if (k === metric) return; // skip selected metric + let val = (typeof v === 'number' && isFinite(v)) ? v.toFixed(4) : v; + let allVals = nodes.map(n => (n.metrics && typeof n.metrics[k] === 'number') ? n.metrics[k] : null).filter(x => x !== null && isFinite(x)); + let minV = allVals.length ? Math.min(...allVals) : 0; + let maxV = allVals.length ? Math.max(...allVals) : 1; + metricsHtml += `
${k}: ${val}${renderMetricBar(v, minV, maxV)}
`; + }); + } + metricsHtml += '
'; + // Flexbox layout: info block | metrics block + row.style.display = 'flex'; + row.style.alignItems = 'stretch'; + row.style.gap = '32px'; + row.style.padding = '12px 8px 0 2em'; + row.style.margin = '0 0 10px 0'; + row.style.borderRadius = '8px'; + row.style.border = selectedProgramId === node.id ? '2.5px solid red' : '1.5px solid #4442'; + row.style.boxShadow = highlightIds.has(node.id) ? '0 0 0 2px #2196f3' : 'none'; + row.style.background = ''; + infoBlock.style.flex = '0 0 auto'; + const metricsBlock = document.createElement('div'); + metricsBlock.innerHTML = metricsHtml; + metricsBlock.className = 'metrics-block-outer'; + metricsBlock.style.flex = '1 1 0%'; + row.appendChild(infoBlock); + + let openLink = `[open in new window]`; + const openDiv = document.createElement('div'); + openDiv.style.textAlign = 'center'; + openDiv.style.margin = '-0.5em 0 0.5em 0'; + openDiv.innerHTML = openLink; + row.appendChild(openDiv); + + row.appendChild(metricsBlock); + + row.onclick = (e) => { + if (e.target.tagName === 'A') return; + if (selectedProgramId !== node.id) { + setSelectedProgramId(node.id); + window._lastSelectedNodeData = node; + setSidebarSticky(true); + renderNodeList(allNodeData); + showSidebarContent(node, false); + showSidebarListView(); + selectProgram(node.id); + selectPerformanceNodeById(node.id); + } + }; + // Parent link logic for list + setTimeout(() => { + const parentLink = row.querySelector('.parent-link'); + if (parentLink && parentLink.dataset.parent && parentLink.dataset.parent !== 'None' && parentLink.dataset.parent !== '') { + parentLink.onclick = function(e) { + e.preventDefault(); + scrollAndSelectNodeById(parentLink.dataset.parent); + }; + } + }, 0); + container.appendChild(row); + }); +} +export function selectListNodeById(id) { + setSelectedProgramId(id); + renderNodeList(allNodeData); + const node = allNodeData.find(n => n.id == id); + if (node) { + window._lastSelectedNodeData = node; + setSidebarSticky(true); + showSidebarContent(node, false); + showSidebarListView(); + } +} + +// List search/sort events +if (document.getElementById('list-search')) { + document.getElementById('list-search').addEventListener('input', () => renderNodeList(allNodeData)); +} +if (document.getElementById('list-sort')) { + document.getElementById('list-sort').addEventListener('change', () => renderNodeList(allNodeData)); +} + +// Highlight select event +const highlightSelect = document.getElementById('highlight-select'); +highlightSelect.addEventListener('change', function() { + renderNodeList(allNodeData); +}); + +if (document.getElementById('list-sort')) { + document.getElementById('list-sort').value = 'score'; +} + +const viewList = document.getElementById('view-list'); +const sidebarEl = document.getElementById('sidebar'); +export function updateListSidebarLayout() { + if (viewList.style.display !== 'none') { + sidebarEl.style.transform = 'translateX(0)'; + viewList.style.marginRight = (sidebarEl.offsetWidth+100) + 'px'; + } else { + viewList.style.marginRight = '0'; + } +} + +function showSidebarListView() { + if (viewList.style.display !== 'none') { + sidebarEl.style.transform = 'translateX(0)'; + viewList.style.marginRight = (sidebarEl.offsetWidth+100) + 'px'; + } else { + showSidebar(); + } +} \ No newline at end of file diff --git a/scripts/static/js/main.js b/scripts/static/js/main.js new file mode 100644 index 000000000..81075036c --- /dev/null +++ b/scripts/static/js/main.js @@ -0,0 +1,210 @@ +// main.js for OpenEvolve Evolution Visualizer + +import { sidebarSticky, showSidebarContent } from './sidebar.js'; +import { updateListSidebarLayout, renderNodeList } from './list.js'; +import { renderGraph, g, getNodeRadius, animateGraphNodeAttributes } from './graph.js'; + +export let allNodeData = []; + +let archiveProgramIds = []; + +const sidebarEl = document.getElementById('sidebar'); + +let lastDataStr = null; +let selectedProgramId = null; + +function formatMetrics(metrics) { + return Object.entries(metrics).map(([k, v]) => `${k}: ${v}`).join('
'); +} + +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)); + } + let minLabel = `${min.toFixed(2)}`; + let maxLabel = `${max.toFixed(2)}`; + if (opts.vertical) { + minLabel = `${min.toFixed(2)}`; + maxLabel = `${max.toFixed(2)}`; + } + return ` + ${minLabel}${maxLabel} + + `; +} + +function loadAndRenderData(data) { + archiveProgramIds = Array.isArray(data.archive) ? data.archive : []; + lastDataStr = JSON.stringify(data); + renderGraph(data); + renderNodeList(data.nodes); + document.getElementById('checkpoint-label').textContent = + "Checkpoint: " + (data.checkpoint_dir || 'static export'); + // Populate metric-select options + const metricSelect = document.getElementById('metric-select'); + metricSelect.innerHTML = ''; + const metrics = new Set(); + data.nodes.forEach(node => { + if (node.metrics) { + Object.keys(node.metrics).forEach(metric => metrics.add(metric)); + } + }); + metrics.forEach(metric => { + const option = document.createElement('option'); + option.value = metric; + option.textContent = metric; + metricSelect.appendChild(option); + }); + if (metricSelect.options.length > 0) { + metricSelect.selectedIndex = 0; + } +} + +if (window.STATIC_DATA) { + loadAndRenderData(window.STATIC_DATA); +} else { + function fetchAndRender() { + fetch('/api/data') + .then(resp => resp.json()) + .then(data => { + const dataStr = JSON.stringify(data); + if (dataStr === lastDataStr) { + return; + } + lastDataStr = dataStr; + loadAndRenderData(data); + }); + } + fetchAndRender(); + setInterval(fetchAndRender, 2000); // Live update every 2s +} + +export let width = window.innerWidth; +export let height = window.innerHeight; + +function resize() { + width = window.innerWidth; + const toolbarHeight = document.getElementById('toolbar').offsetHeight; + height = window.innerHeight - toolbarHeight; + // Re-render the graph with new width/height and latest data + // allNodeData may be [] on first load, so only re-render if nodes exist + if (allNodeData && allNodeData.length > 0) { + // Find edges from lastDataStr if possible, else from allNodeData + let edges = []; + if (typeof lastDataStr === 'string') { + try { + const parsed = JSON.parse(lastDataStr); + edges = parsed.edges || []; + } catch {} + } + renderGraph({ nodes: allNodeData, edges }); + } +} +window.addEventListener('resize', resize); + +// Highlight logic for graph and list views +function getHighlightNodes(nodes, filter, metric) { + if (!filter) return []; + if (filter === 'top') { + let best = -Infinity; + nodes.forEach(n => { + if (n.metrics && typeof n.metrics[metric] === 'number') { + if (n.metrics[metric] > best) best = n.metrics[metric]; + } + }); + return nodes.filter(n => n.metrics && n.metrics[metric] === best); + } else if (filter === 'first') { + return nodes.filter(n => n.generation === 0); + } else if (filter === 'failed') { + return nodes.filter(n => n.metrics && n.metrics.error != null); + } else if (filter === 'unset') { + return nodes.filter(n => !n.metrics || n.metrics[metric] == null); + } else if (filter === 'archive') { + return nodes.filter(n => archiveProgramIds.includes(n.id)); + } + return []; +} + +function getSelectedMetric() { + const metricSelect = document.getElementById('metric-select'); + return metricSelect && metricSelect.value ? metricSelect.value : 'combined_score'; +} + +(function() { + const toolbar = document.getElementById('toolbar'); + const metricSelect = document.getElementById('metric-select'); + const highlightSelect = document.getElementById('highlight-select'); + if (toolbar && metricSelect && highlightSelect) { + // Only move if both are direct children of toolbar and not already in order + if ( + metricSelect.parentElement === toolbar && + highlightSelect.parentElement === toolbar && + toolbar.children.length > 0 && + highlightSelect.previousElementSibling !== metricSelect + ) { + toolbar.insertBefore(metricSelect, highlightSelect); + } + } +})(); + +// Add event listener to re-highlight nodes on highlight-select change (no full rerender) +const highlightSelect = document.getElementById('highlight-select'); +highlightSelect.addEventListener('change', function() { + animateGraphNodeAttributes(); + // Update list view + const container = document.getElementById('node-list-container'); + if (container) { + Array.from(container.children).forEach(div => { + const nodeId = div.innerHTML.match(/ID:<\/b>\s*([^<]+)/); + if (nodeId && nodeId[1]) { + div.classList.toggle('highlighted', getHighlightNodes(allNodeData, highlightSelect.value, getSelectedMetric()).map(n => n.id).includes(nodeId[1])); + } + }); + } +}); + +// Add event listener to re-highlight nodes and update radii on metric-select change (no full rerender) +const metricSelect = document.getElementById('metric-select'); +metricSelect.addEventListener('change', function() { + animateGraphNodeAttributes(); + renderNodeList(allNodeData); +}); + + +// Call on tab switch and window resize +['resize', 'DOMContentLoaded'].forEach(evt => window.addEventListener(evt, updateListSidebarLayout)); +document.getElementById('tab-list').addEventListener('click', updateListSidebarLayout); +document.getElementById('tab-branching').addEventListener('click', function() { + // Hide sidebar if it was hidden in branching + const viewList = document.getElementById('view-list'); + if (sidebarEl.style.transform === 'translateX(100%)') { + sidebarEl.style.transform = 'translateX(100%)'; + } + viewList.style.marginRight = '0'; +}); + + + +// --- Add highlight option for MAP-elites archive --- +(function() { + const highlightSelect = document.getElementById('highlight-select'); + if (highlightSelect && !Array.from(highlightSelect.options).some(o => o.value === 'archive')) { + const opt = document.createElement('option'); + opt.value = 'archive'; + opt.textContent = 'MAP-elites archive'; + highlightSelect.appendChild(opt); + } +})(); + +// Export all shared state and helpers for use in other modules +export function setAllNodeData(nodes) { + allNodeData = nodes; +} + +export function setSelectedProgramId(id) { + selectedProgramId = id; +} + +export { archiveProgramIds, lastDataStr, selectedProgramId, formatMetrics, renderMetricBar, getHighlightNodes, getSelectedMetric }; diff --git a/scripts/static/js/mainUI.js b/scripts/static/js/mainUI.js new file mode 100644 index 000000000..8f652f892 --- /dev/null +++ b/scripts/static/js/mainUI.js @@ -0,0 +1,85 @@ +import { width, height } from './state.js'; +import { selectedProgramId } from './main.js'; +import { selectProgram } from './graph.js'; +import { showSidebarContent } from './sidebar.js'; + +const darkToggleContainer = document.getElementById('darkmode-toggle').parentElement; +const darkToggleInput = document.getElementById('darkmode-toggle'); +const darkToggleLabel = document.getElementById('darkmode-label'); + +if (!document.getElementById('custom-dark-toggle')) { + const wrapper = document.createElement('label'); + wrapper.className = 'toggle-switch'; + wrapper.id = 'custom-dark-toggle'; + const input = document.createElement('input'); + input.type = 'checkbox'; + input.id = 'darkmode-toggle'; + input.checked = darkToggleInput.checked; + const slider = document.createElement('span'); + slider.className = 'toggle-slider'; + wrapper.appendChild(input); + wrapper.appendChild(slider); + darkToggleContainer.replaceChild(wrapper, darkToggleInput); + + darkToggleContainer.appendChild(darkToggleLabel); + input.addEventListener('change', function() { + setTheme(this.checked ? 'dark' : 'light'); + }); +} + +// Tab switching logic +const tabs = ["branching", "performance", "list"]; +tabs.forEach(tab => { + document.getElementById(`tab-${tab}`).addEventListener('click', function() { + tabs.forEach(t => { + document.getElementById(`tab-${t}`).classList.remove('active'); + const view = document.getElementById(`view-${t}`); + if (view) view.style.display = 'none'; + }); + this.classList.add('active'); + const view = document.getElementById(`view-${tab}`); + if (view) view.style.display = 'block'; + // Synchronize node selection when switching tabs + if (tab === 'list' || tab === 'branching') { + if (selectedProgramId) { + selectProgram(selectedProgramId); + showSidebarContent(window._lastSelectedNodeData || null); + } + } + }); +}); + +// Dark mode logic +function setTheme(theme) { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem('theme', theme); + document.getElementById('darkmode-toggle').checked = (theme === 'dark'); + document.getElementById('darkmode-label').textContent = theme === 'dark' ? '🌙' : '☀️'; +} +function getSystemTheme() { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} +// On load, use localStorage or system default to determine theme +(function() { + let theme = localStorage.getItem('theme'); + if (!theme) theme = getSystemTheme(); + setTheme(theme); +})(); +document.getElementById('darkmode-toggle').addEventListener('change', function() { + setTheme(this.checked ? 'dark' : 'light'); +}); + +// Canvas size and zoom setup +let toolbarHeight = document.getElementById('toolbar').offsetHeight; + +const svg = d3.select("#graph").append("svg") + .attr("width", width) + .attr("height", height) + .call(d3.zoom() + .scaleExtent([0.1, 10]) + .on("zoom", (event) => { + g.attr("transform", event.transform); + })) + .on("dblclick.zoom", null); + +const g = svg.append("g"); diff --git a/scripts/static/js/performance.js b/scripts/static/js/performance.js new file mode 100644 index 000000000..9fd235a64 --- /dev/null +++ b/scripts/static/js/performance.js @@ -0,0 +1,545 @@ +import { allNodeData, archiveProgramIds, formatMetrics, renderMetricBar, getHighlightNodes, getSelectedMetric, selectedProgramId, setSelectedProgramId } from './main.js'; +import { getNodeRadius, getNodeColor, selectProgram, scrollAndSelectNodeById } from './graph.js'; +import { hideSidebar, sidebarSticky, showSidebarContent, showSidebar, setSidebarSticky } from './sidebar.js'; +import { selectListNodeById } from './list.js'; + +(function() { + window.addEventListener('DOMContentLoaded', function() { + const perfDiv = document.getElementById('view-performance'); + if (!perfDiv) return; + let toggleDiv = document.getElementById('perf-island-toggle'); + if (!toggleDiv) { + toggleDiv = document.createElement('div'); + toggleDiv.id = 'perf-island-toggle'; + toggleDiv.style = 'display:flex;align-items:center;gap:0.7em;'; + toggleDiv.innerHTML = ` + + Show islands + `; + perfDiv.insertBefore(toggleDiv, perfDiv.firstChild); + } + function animatePerformanceGraphAttributes() { + const svg = d3.select('#performance-graph'); + if (svg.empty()) return; + const g = svg.select('g.zoom-group'); + if (g.empty()) return; + const metric = getSelectedMetric(); + const highlightFilter = document.getElementById('highlight-select').value; + const showIslands = document.getElementById('show-islands-toggle')?.checked; + const nodes = allNodeData; + const validNodes = nodes.filter(n => n.metrics && typeof n.metrics[metric] === 'number'); + const undefinedNodes = nodes.filter(n => !n.metrics || n.metrics[metric] == null || isNaN(n.metrics[metric])); + let islands = []; + if (showIslands) { + islands = Array.from(new Set(nodes.map(n => n.island))).sort((a,b)=>a-b); + } else { + islands = [null]; + } + const yExtent = d3.extent(nodes, d => d.generation); + const minGen = 0; + const maxGen = yExtent[1]; + const margin = {top: 60, right: 40, bottom: 40, left: 60}; + let undefinedBoxWidth = 70; + const undefinedBoxPad = 54; + const graphXOffset = undefinedBoxWidth + undefinedBoxPad; + const width = +svg.attr('width'); + const height = +svg.attr('height'); + const graphHeight = Math.max(400, (maxGen - minGen + 1) * 48 + margin.top + margin.bottom); + let yScales = {}; + islands.forEach((island, i) => { + yScales[island] = d3.scaleLinear() + .domain([minGen, maxGen]).nice() + .range([margin.top + i*graphHeight, margin.top + (i+1)*graphHeight - margin.bottom]); + }); + const xExtent = d3.extent(validNodes, d => d.metrics[metric]); + const x = d3.scaleLinear() + .domain([xExtent[0], xExtent[1]]).nice() + .range([margin.left+graphXOffset, width - margin.right]); + const highlightNodes = getHighlightNodes(nodes, highlightFilter, metric); + const highlightIds = new Set(highlightNodes.map(n => n.id)); + // Animate valid nodes + g.selectAll('circle') + .filter(function(d) { return validNodes.includes(d); }) + .transition().duration(400) + .attr('cx', d => x(d.metrics[metric])) + .attr('cy', d => showIslands ? yScales[d.island](d.generation) : yScales[null](d.generation)) + .attr('r', d => getNodeRadius(d)) + .attr('fill', d => getNodeColor(d)) + .attr('stroke', d => selectedProgramId === d.id ? 'red' : (highlightIds.has(d.id) ? '#2196f3' : '#333')) + .attr('stroke-width', d => selectedProgramId === d.id ? 3 : 1.5) + .attr('opacity', 0.85) + .on('end', null) + .selection() + .each(function(d) { + d3.select(this) + .classed('node-highlighted', highlightIds.has(d.id)) + .classed('node-selected', selectedProgramId === d.id); + }); + // Animate undefined nodes (NaN box) + g.selectAll('circle') + .filter(function(d) { return undefinedNodes.includes(d); }) + .transition().duration(400) + .attr('cx', margin.left + undefinedBoxWidth/2) + .attr('cy', d => yScales[showIslands ? d.island : null](d.generation)) + .attr('r', d => getNodeRadius(d)) + .attr('fill', d => getNodeColor(d)) + .attr('stroke', d => selectedProgramId === d.id ? 'red' : '#333') + .attr('stroke-width', d => selectedProgramId === d.id ? 3 : 1.5) + .attr('opacity', 0.85) + .on('end', null) + .selection() + .each(function(d) { + d3.select(this) + .classed('node-selected', selectedProgramId === d.id); + }); + // Animate 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 => { + return { + source: nodeById[n.parent_id], + target: n + }; + }); + g.selectAll('line.performance-edge') + .data(edges, d => d.target.id) + .transition().duration(400) + .attr('x1', d => { + const m = d.source.metrics && typeof d.source.metrics[metric] === 'number' ? d.source.metrics[metric] : null; + if (m === null || isNaN(m)) { + return margin.left + undefinedBoxWidth/2; + } else { + return x(m); + } + }) + .attr('y1', d => { + const m = d.source.metrics && typeof d.source.metrics[metric] === 'number' ? d.source.metrics[metric] : null; + const island = showIslands ? d.source.island : null; + return yScales[island](d.source.generation); + }) + .attr('x2', d => { + const m = d.target.metrics && typeof d.target.metrics[metric] === 'number' ? d.target.metrics[metric] : null; + if (m === null || isNaN(m)) { + return margin.left + undefinedBoxWidth/2; + } else { + return x(m); + } + }) + .attr('y2', d => { + const m = d.target.metrics && typeof d.target.metrics[metric] === 'number' ? d.target.metrics[metric] : null; + const island = showIslands ? d.target.island : null; + return yScales[island](d.target.generation); + }) + .attr('stroke', '#888') + .attr('stroke-width', 1.5) + .attr('opacity', 0.5); + } + const metricSelect = document.getElementById('metric-select'); + metricSelect.addEventListener('change', function() { + updatePerformanceGraph(allNodeData); + }); + const highlightSelect = document.getElementById('highlight-select'); + highlightSelect.addEventListener('change', function() { + animatePerformanceGraphAttributes(); + }); + document.getElementById('tab-performance').addEventListener('click', function() { + if (typeof allNodeData !== 'undefined' && allNodeData.length) { + updatePerformanceGraph(allNodeData); + } + }); + // Show islands yes/no toggle event + document.getElementById('show-islands-toggle').addEventListener('change', function() { + updatePerformanceGraph(allNodeData); + }); + // Responsive resize + window.addEventListener('resize', function() { + if (typeof allNodeData !== 'undefined' && allNodeData.length && perfDiv.style.display !== 'none') { + updatePerformanceGraph(allNodeData); + } + }); + window.updatePerformanceGraph = updatePerformanceGraph; + + // Initial render + if (typeof allNodeData !== 'undefined' && allNodeData.length) { + updatePerformanceGraph(allNodeData); + } + }); +})(); + +// Select a node by ID and update graph and sidebar +export function selectPerformanceNodeById(id, opts = {}) { + setSelectedProgramId(id); + setSidebarSticky(true); + if (typeof allNodeData !== 'undefined' && allNodeData.length) { + updatePerformanceGraph(allNodeData, opts); + const node = allNodeData.find(n => n.id == id); + if (node) showSidebarContent(node, false); + } +} + +export function centerAndHighlightNodeInPerformanceGraph(nodeId) { + if (!g || !svg) return; + // Ensure zoomBehavior is available and is a function + if (!zoomBehavior || typeof zoomBehavior !== 'function') { + zoomBehavior = d3.zoom() + .scaleExtent([0.2, 10]) + .on('zoom', function(event) { + g.attr('transform', event.transform); + lastTransform = event.transform; + }); + svg.call(zoomBehavior); + } + // Try both valid and NaN nodes + let nodeSel = g.selectAll('circle.performance-node').filter(d => d.id == nodeId); + if (nodeSel.empty()) { + nodeSel = g.selectAll('circle.performance-nan').filter(d => d.id == nodeId); + } + if (!nodeSel.empty()) { + const node = nodeSel.node(); + const bbox = node.getBBox(); + const graphW = svg.attr('width'); + const graphH = svg.attr('height'); + const scale = Math.min(graphW / (bbox.width * 6), graphH / (bbox.height * 6), 1.5); + const tx = graphW/2 - scale * (bbox.x + bbox.width/2); + const ty = graphH/2 - scale * (bbox.y + bbox.height/2); + const t = d3.zoomIdentity.translate(tx, ty).scale(scale); + // Use the correct D3 v7 API for programmatic zoom + svg.transition().duration(400).call(zoomBehavior.transform, t); + // Yellow shadow highlight + nodeSel.each(function() { + const el = d3.select(this); + el.classed('node-locator-highlight', true) + .style('filter', 'drop-shadow(0 0 16px 8px #FFD600)'); + el.transition().duration(350).style('filter', 'drop-shadow(0 0 24px 16px #FFD600)') + .transition().duration(650).style('filter', null) + .on('end', function() { el.classed('node-locator-highlight', false); }); + }); + } +} + +let svg = null; +let g = null; +let zoomBehavior = null; +let lastTransform = null; + +function updatePerformanceGraph(nodes, options = {}) { + // Get or create SVG + if (!svg) { + svg = d3.select('#performance-graph'); + if (svg.empty()) { + svg = d3.select('#view-performance') + .append('svg') + .attr('id', 'performance-graph') + .style('display', 'block'); + } + } + // Get or create group + g = svg.select('g.zoom-group'); + if (g.empty()) { + g = svg.append('g').attr('class', 'zoom-group'); + } + // Setup zoom behavior only once + if (!zoomBehavior) { + zoomBehavior = d3.zoom() + .scaleExtent([0.2, 10]) + .on('zoom', function(event) { + g.attr('transform', event.transform); + lastTransform = event.transform; + }); + svg.call(zoomBehavior); + } + // Reapply last transform after update + if (lastTransform) { + svg.call(zoomBehavior.transform, lastTransform); + } + // Add SVG background click handler for unselect + svg.on('click', function(event) { + if (event.target === svg.node()) { + setSelectedProgramId(null); + setSidebarSticky(false); + hideSidebar(); + // Remove selection from all nodes + g.selectAll('circle.performance-node, circle.performance-nan') + .classed('node-selected', false) + .attr('stroke', function(d) { + // Use highlight color if highlighted, else default + const highlightFilter = document.getElementById('highlight-select').value; + const highlightNodes = getHighlightNodes(nodes, highlightFilter, getSelectedMetric()); + const highlightIds = new Set(highlightNodes.map(n => n.id)); + return highlightIds.has(d.id) ? '#2196f3' : '#333'; + }) + .attr('stroke-width', 1.5); + selectListNodeById(null); + } + }); + // Sizing + const sidebarEl = document.getElementById('sidebar'); + const padding = 32; + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + const toolbarHeight = document.getElementById('toolbar').offsetHeight; + const sidebarWidth = sidebarEl.offsetWidth || 400; + const width = Math.max(windowWidth - sidebarWidth - padding, 400); + const metric = getSelectedMetric(); + const validNodes = nodes.filter(n => n.metrics && typeof n.metrics[metric] === 'number'); + const undefinedNodes = nodes.filter(n => !n.metrics || n.metrics[metric] == null || isNaN(n.metrics[metric])); + const showIslands = document.getElementById('show-islands-toggle')?.checked; + let islands = []; + if (showIslands) { + islands = Array.from(new Set(nodes.map(n => n.island))).sort((a,b)=>a-b); + } else { + islands = [null]; + } + const yExtent = d3.extent(nodes, d => d.generation); + const minGen = 0; + const maxGen = yExtent[1]; + const margin = {top: 60, right: 40, bottom: 40, left: 60}; + let undefinedBoxWidth = 70; + const undefinedBoxPad = 54; + const genCount = (maxGen - minGen + 1) || 1; + const graphHeight = Math.max(400, genCount * 48 + margin.top + margin.bottom); + const totalGraphHeight = showIslands ? (graphHeight * islands.length) : graphHeight; + const svgHeight = Math.max(windowHeight - toolbarHeight - 24, totalGraphHeight); + const graphXOffset = undefinedBoxWidth + undefinedBoxPad; + svg.attr('width', width).attr('height', svgHeight); + // Remove old axes/labels + g.selectAll('.axis, .axis-label, .island-label, .nan-label, .nan-box').remove(); + // Y scales per island + let yScales = {}; + islands.forEach((island, i) => { + yScales[island] = d3.scaleLinear() + .domain([minGen, maxGen]).nice() + .range([margin.top + i*graphHeight, margin.top + (i+1)*graphHeight - margin.bottom]); + // Y axis + g.append('g') + .attr('class', 'axis') + .attr('transform', `translate(${margin.left+graphXOffset},0)`) + .call(d3.axisLeft(yScales[island]).ticks(Math.min(12, genCount))); + // Y axis label + g.append('text') + .attr('class', 'axis-label') + .attr('transform', `rotate(-90)`) // vertical + .attr('y', margin.left + 8) + .attr('x', -(margin.top + i*graphHeight + (graphHeight - margin.top - margin.bottom)/2)) + .attr('dy', '-2.2em') + .attr('text-anchor', 'middle') + .attr('font-size', '1em') + .attr('fill', '#888') + .text('Generation'); + // Island label + if (showIslands) { + g.append('text') + .attr('class', 'island-label') + .attr('x', (width + undefinedBoxWidth) / 2) + .attr('y', margin.top + i*graphHeight + 38) + .attr('text-anchor', 'middle') + .attr('font-size', '2.1em') + .attr('font-weight', 700) + .attr('fill', '#444') + .attr('pointer-events', 'none') + .text(`Island ${island}`); + } + }); + // X axis + const xExtent = d3.extent(validNodes, d => d.metrics[metric]); + const x = d3.scaleLinear() + .domain([xExtent[0], xExtent[1]]).nice() + .range([margin.left+graphXOffset, width - margin.right]); + // Remove old x axis and label only + g.selectAll('.x-axis, .x-axis-label').remove(); + // Add x axis group + g.append('g') + .attr('class', 'axis x-axis') + .attr('transform', `translate(0,${margin.top})`) + .call(d3.axisTop(x)); + // Add x axis label + g.append('text') + .attr('class', 'x-axis-label') + .attr('x', (width + undefinedBoxWidth) / 2) + .attr('y', margin.top - 28) // just below the axis + .attr('fill', '#888') + .attr('text-anchor', 'middle') + .attr('font-size', '1.1em') + .text(metric); + // NaN box + if (undefinedNodes.length) { + 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('y', boxTop - 10) + .attr('text-anchor', 'middle') + .attr('font-size', '0.92em') + .attr('fill', '#888') + .text('NaN'); + g.append('rect') + .attr('class', 'nan-box') + .attr('x', margin.left) + .attr('y', boxTop) + .attr('width', undefinedBoxWidth) + .attr('height', boxBottom - boxTop) + .attr('fill', 'none') + .attr('stroke', '#bbb') + .attr('stroke-width', 1.5) + .attr('rx', 12); + } + // 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() + .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(); + // Data join for nodes + const highlightFilter = document.getElementById('highlight-select').value; + const highlightNodes = getHighlightNodes(nodes, highlightFilter, metric); + const highlightIds = new Set(highlightNodes.map(n => n.id)); + const nodeSel = g.selectAll('circle.performance-node') + .data(validNodes, d => d.id); + nodeSel.enter() + .append('circle') + .attr('class', 'performance-node') + .attr('cx', d => x(d.metrics[metric])) + .attr('cy', d => showIslands ? yScales[d.island](d.generation) : yScales[null](d.generation)) + .attr('r', d => getNodeRadius(d)) + .attr('fill', d => getNodeColor(d)) + .attr('stroke', d => selectedProgramId === d.id ? 'red' : (highlightIds.has(d.id) ? '#2196f3' : '#333')) + .attr('stroke-width', d => selectedProgramId === d.id ? 3 : 1.5) + .attr('opacity', 0.85) + .on('mouseover', function(event, d) { + if (!sidebarSticky && (!selectedProgramId || selectedProgramId !== d.id)) { + showSidebarContent(d, true); + showSidebar(); + } + d3.select(this) + .classed('node-hovered', true) + .attr('stroke', '#FFD600').attr('stroke-width', 4); + }) + .on('mouseout', function(event, d) { + d3.select(this) + .classed('node-hovered', false) + .attr('stroke', selectedProgramId === d.id ? 'red' : (highlightIds.has(d.id) ? '#2196f3' : '#333')) + .attr('stroke-width', selectedProgramId === d.id ? 3 : 1.5); + if (!selectedProgramId) { + hideSidebar(); + } + }) + .on('click', function(event, d) { + event.preventDefault(); + setSelectedProgramId(d.id); + window._lastSelectedNodeData = d; + setSidebarSticky(true); + selectListNodeById(d.id); + g.selectAll('circle.performance-node').classed('node-hovered', false).classed('node-selected', false) + .attr('stroke', function(nd) { + return selectedProgramId === nd.id ? 'red' : (highlightIds.has(nd.id) ? '#2196f3' : '#333'); + }) + .attr('stroke-width', function(nd) { + return selectedProgramId === nd.id ? 3 : 1.5; + }); + d3.select(this).classed('node-selected', true); + showSidebarContent(d, false); + showSidebar(); + selectProgram(selectedProgramId); + }) + .merge(nodeSel) + .transition().duration(500) + .attr('cx', d => x(d.metrics[metric])) + .attr('cy', d => showIslands ? yScales[d.island](d.generation) : yScales[null](d.generation)) + .attr('r', d => getNodeRadius(d)) + .attr('fill', d => getNodeColor(d)) + .attr('stroke', d => selectedProgramId === d.id ? 'red' : (highlightIds.has(d.id) ? '#2196f3' : '#333')) + .attr('stroke-width', d => selectedProgramId === d.id ? 3 : 1.5) + .attr('opacity', 0.85) + .on('end', null) + .selection() + .each(function(d) { + d3.select(this) + .classed('node-highlighted', highlightIds.has(d.id)) + .classed('node-selected', selectedProgramId === d.id); + }); + nodeSel.exit().transition().duration(300).attr('opacity', 0).remove(); + // Data join for NaN nodes + const nanSel = g.selectAll('circle.performance-nan') + .data(undefinedNodes, d => d.id); + nanSel.enter() + .append('circle') + .attr('class', 'performance-nan') + .attr('cx', margin.left + undefinedBoxWidth/2) + .attr('cy', d => yScales[showIslands ? d.island : null](d.generation)) + .attr('r', d => getNodeRadius(d)) + .attr('fill', d => getNodeColor(d)) + .attr('stroke', d => selectedProgramId === d.id ? 'red' : '#333') + .attr('stroke-width', d => selectedProgramId === d.id ? 3 : 1.5) + .attr('opacity', 0.85) + .on('mouseover', function(event, d) { + if (!sidebarSticky && (!selectedProgramId || selectedProgramId !== d.id)) { + showSidebarContent(d, true); + showSidebar(); + } + d3.select(this) + .classed('node-hovered', true) + .attr('stroke', '#FFD600').attr('stroke-width', 4); + }) + .on('mouseout', function(event, d) { + d3.select(this) + .classed('node-hovered', false) + .attr('stroke', selectedProgramId === d.id ? 'red' : '#333') + .attr('stroke-width', selectedProgramId === d.id ? 3 : 1.5); + if (!selectedProgramId) { + hideSidebar(); + } + }) + .on('click', function(event, d) { + event.preventDefault(); + setSelectedProgramId(d.id); + window._lastSelectedNodeData = d; + setSidebarSticky(true); + selectListNodeById(d.id); + g.selectAll('circle.performance-nan').classed('node-hovered', false).classed('node-selected', false) + .attr('stroke', function(nd) { + return selectedProgramId === nd.id ? 'red' : '#333'; + }) + .attr('stroke-width', function(nd) { + return selectedProgramId === nd.id ? 3 : 1.5; + }); + d3.select(this).classed('node-selected', true); + showSidebarContent(d, false); + showSidebar(); + selectProgram(selectedProgramId); + }) + .merge(nanSel) + .transition().duration(500) + .attr('cx', margin.left + undefinedBoxWidth/2) + .attr('cy', d => yScales[showIslands ? d.island : null](d.generation)) + .attr('r', d => getNodeRadius(d)) + .attr('fill', d => getNodeColor(d)) + .attr('stroke', d => selectedProgramId === d.id ? 'red' : '#333') + .attr('stroke-width', d => selectedProgramId === d.id ? 3 : 1.5) + .attr('opacity', 0.85) + .on('end', null) + .selection() + .each(function(d) { + d3.select(this) + .classed('node-selected', selectedProgramId === d.id); + }); + nanSel.exit().transition().duration(300).attr('opacity', 0).remove(); +} diff --git a/scripts/static/js/sidebar.js b/scripts/static/js/sidebar.js new file mode 100644 index 000000000..85eaa5f38 --- /dev/null +++ b/scripts/static/js/sidebar.js @@ -0,0 +1,157 @@ +import { allNodeData, archiveProgramIds, formatMetrics, renderMetricBar, getHighlightNodes, selectedProgramId, setSelectedProgramId } from './main.js'; +import { scrollAndSelectNodeById } from './graph.js'; + +const sidebar = document.getElementById('sidebar'); +export let sidebarSticky = false; + +export function showSidebar() { + sidebar.style.transform = 'translateX(0)'; +} +export function hideSidebar() { + sidebar.style.transform = 'translateX(100%)'; + sidebarSticky = false; +} + +export function showSidebarContent(d, fromHover = false) { + const sidebarContent = document.getElementById('sidebar-content'); + if (!sidebarContent) return; + if (fromHover && sidebarSticky) return; + if (!d) { + sidebarContent.innerHTML = ''; + return; + } + let starHtml = ''; + if (archiveProgramIds && archiveProgramIds.includes(d.id)) { + starHtml = ''; + } + // Locator icon button (left of close X) + let locatorBtn = ''; + let closeBtn = ''; + let openLink = '
[open in new window]
'; + let tabHtml = ''; + let tabContentHtml = ''; + 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 = ''; + } + let parentIslandHtml = ''; + if (d.parent_id && d.parent_id !== 'None') { + const parent = allNodeData.find(n => n.id == d.parent_id); + if (parent && parent.island !== undefined) { + parentIslandHtml = ` (island ${parent.island})`; + } + } + sidebarContent.innerHTML = + `
+ ${starHtml} + ${locatorBtn} + ${closeBtn} + ${openLink} + Program ID: ${d.id}
+ Island: ${d.island}
+ Generation: ${d.generation}
+ Parent ID: ${d.parent_id || 'None'}${parentIslandHtml}

+ Metrics:
${formatMetrics(d.metrics)}

+ ${tabHtml}${tabContentHtml} +
`; + if (tabNames.length > 1) { + const tabBar = document.getElementById('sidebar-tab-bar'); + Array.from(tabBar.children).forEach(tabEl => { + tabEl.onclick = function() { + Array.from(tabBar.children).forEach(e => e.classList.remove('active')); + tabEl.classList.add('active'); + const tabName = tabEl.dataset.tab; + 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 = html; + } + }; + }); + } + const closeBtnEl = document.getElementById('sidebar-close-btn'); + if (closeBtnEl) closeBtnEl.onclick = function() { + setSelectedProgramId(null); + sidebarSticky = false; + hideSidebar(); + }; + // Locator button logic + const locatorBtnEl = document.getElementById('sidebar-locator-btn'); + if (locatorBtnEl) { + locatorBtnEl.onclick = function(e) { + e.preventDefault(); + // Use view display property for active view detection + const viewBranching = document.getElementById('view-branching'); + const viewPerformance = document.getElementById('view-performance'); + const viewList = document.getElementById('view-list'); + if (viewBranching && viewBranching.style.display !== 'none') { + import('./graph.js').then(mod => { + mod.centerAndHighlightNodeInGraph(d.id); + }); + } else if (viewPerformance && viewPerformance.style.display !== 'none') { + import('./performance.js').then(mod => { + mod.centerAndHighlightNodeInPerformanceGraph(d.id); + }); + } else if (viewList && viewList.style.display !== 'none') { + // Scroll to list item + const container = document.getElementById('node-list-container'); + if (container) { + const rows = Array.from(container.children); + const target = rows.find(div => div.getAttribute('data-node-id') === d.id); + if (target) { + target.scrollIntoView({behavior: 'smooth', block: 'center'}); + // Optionally add a yellow highlight effect + target.classList.add('node-locator-highlight'); + setTimeout(() => target.classList.remove('node-locator-highlight'), 1000); + } + } + } + }; + } + // Parent link logic + const parentLink = sidebarContent.querySelector('.parent-link'); + if (parentLink && parentLink.dataset.parent && parentLink.dataset.parent !== 'None' && parentLink.dataset.parent !== '') { + parentLink.onclick = function(e) { + e.preventDefault(); + const parentNode = allNodeData.find(n => n.id == parentLink.dataset.parent); + if (parentNode) { + window._lastSelectedNodeData = parentNode; + } + 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(parentLink.dataset.parent); + showSidebar(); + }); + } else { + scrollAndSelectNodeById(parentLink.dataset.parent); + } + }; + } +} + +export function openInNewTab(event, d) { + const url = `/program/${d.id}`; + window.open(url, '_blank'); + event.stopPropagation(); +} + +export function setSidebarSticky(val) { + sidebarSticky = val; +} \ No newline at end of file diff --git a/scripts/static/js/state.js b/scripts/static/js/state.js new file mode 100644 index 000000000..65c6a33fd --- /dev/null +++ b/scripts/static/js/state.js @@ -0,0 +1,11 @@ +export let width = window.innerWidth; +export let height = window.innerHeight; + +export function setWidth(w) { width = w; } +export function setHeight(h) { height = h; } +export function updateDimensions() { + width = window.innerWidth; + const toolbar = document.getElementById('toolbar'); + const toolbarHeight = toolbar ? toolbar.offsetHeight : 0; + height = window.innerHeight - toolbarHeight; +} diff --git a/scripts/templates/index.html b/scripts/templates/index.html new file mode 100644 index 000000000..ec3cc5589 --- /dev/null +++ b/scripts/templates/index.html @@ -0,0 +1,71 @@ + + + + + OpenEvolve Evolution Visualizer + + + + +
+
+ OpenEvolve Evolution Visualizer + Checkpoint: None +
+
+
+
Branching
+
Performance
+
List
+
+ + +
+ + + 🌙 +
+
+ +
+
+
+ +
+
+ + + + + + + + + \ No newline at end of file diff --git a/scripts/templates/program_page.html b/scripts/templates/program_page.html new file mode 100644 index 000000000..2c5d70ffa --- /dev/null +++ b/scripts/templates/program_page.html @@ -0,0 +1,35 @@ + + + + + Program {{ program_data.id }} + + + +

Program ID: {{ program_data.id }}

+ +

Code:

+
{{ program_data.code }}
+

Prompts:

+ + + \ No newline at end of file diff --git a/scripts/visualizer.py b/scripts/visualizer.py new file mode 100644 index 000000000..081f8efc9 --- /dev/null +++ b/scripts/visualizer.py @@ -0,0 +1,188 @@ +import os +import json +import glob +import logging +import shutil +import re as _re +from flask import Flask, render_template, render_template_string, jsonify + + +logger = logging.getLogger("openevolve.visualizer") +app = Flask(__name__, template_folder="templates") + + +def find_latest_checkpoint(base_folder): + # Check whether the base folder is itself a checkpoint folder + if os.path.basename(base_folder).startswith("checkpoint_"): + return base_folder + + checkpoint_folders = glob.glob("**/checkpoint_*", root_dir=base_folder, recursive=True) + if not checkpoint_folders: + logger.info(f"No checkpoint folders found in {base_folder}") + return None + checkpoint_folders = [os.path.join(base_folder, folder) for folder in checkpoint_folders] + checkpoint_folders.sort(key=lambda x: os.path.getmtime(x), reverse=True) + logger.debug(f"Found checkpoint folder: {checkpoint_folders[0]}") + return checkpoint_folders[0] + + +def load_evolution_data(checkpoint_folder): + meta_path = os.path.join(checkpoint_folder, "metadata.json") + programs_dir = os.path.join(checkpoint_folder, "programs") + if not os.path.exists(meta_path) or not os.path.exists(programs_dir): + logger.info(f"Missing metadata.json or programs dir in {checkpoint_folder}") + return {"archive": [], "nodes": [], "edges": [], "checkpoint_dir": checkpoint_folder} + with open(meta_path) as f: + meta = json.load(f) + + nodes = [] + id_to_program = {} + for island_idx, id_list in enumerate(meta.get("islands", [])): + for pid in id_list: + prog_path = os.path.join(programs_dir, f"{pid}.json") + if os.path.exists(prog_path): + with open(prog_path) as pf: + prog = json.load(pf) + prog["island"] = island_idx + nodes.append(prog) + id_to_program[pid] = prog + else: + logger.debug(f"Program file not found: {prog_path}") + + edges = [] + for prog in nodes: + parent_id = prog.get("parent_id") + if parent_id and parent_id in id_to_program: + edges.append({"source": parent_id, "target": prog["id"]}) + + logger.info(f"Loaded {len(nodes)} nodes and {len(edges)} edges from {checkpoint_folder}") + return { + "archive": meta.get("archive", []), + "nodes": nodes, + "edges": edges, + "checkpoint_dir": checkpoint_folder, + } + + +@app.route("/") +def index(): + return render_template("index.html", checkpoint_dir=checkpoint_dir) + + +checkpoint_dir = None # Global variable to store the checkpoint directory + + +@app.route("/api/data") +def data(): + global checkpoint_dir + base_folder = os.environ.get("EVOLVE_OUTPUT", "examples/") + checkpoint_dir = find_latest_checkpoint(base_folder) + if not checkpoint_dir: + logger.info(f"No checkpoints found in {base_folder}") + return jsonify({"archive": [], "nodes": [], "edges": [], "checkpoint_dir": ""}) + + logger.info(f"Loading data from checkpoint: {checkpoint_dir}") + data = load_evolution_data(checkpoint_dir) + logger.debug(f"Data: {data}") + return jsonify(data) + + +@app.route("/program/") +def program_page(program_id): + global checkpoint_dir + if checkpoint_dir is None: + return "No checkpoint loaded", 500 + + data = load_evolution_data(checkpoint_dir) + program_data = next((p for p in data["nodes"] if p["id"] == program_id), None) + program_data = {"code": "", "prompts": {}, **program_data} + + return render_template( + "program_page.html", program_data=program_data, checkpoint_dir=checkpoint_dir + ) + + +def run_static_export(args): + output_dir = args.static_output + os.makedirs(output_dir, exist_ok=True) + + # Load data and prepare JSON string + checkpoint_dir = find_latest_checkpoint(args.path) + if not checkpoint_dir: + raise RuntimeError(f"No checkpoint found in {args.path}") + data = load_evolution_data(checkpoint_dir) + logger.info(f"Exporting visualization for checkpoint: {checkpoint_dir}") + + with app.app_context(): + data_json = jsonify(data).get_data(as_text=True) + inlined = f"" + + # Load index.html template + templates_dir = os.path.join(os.path.dirname(__file__), "templates") + template_path = os.path.join(templates_dir, "index.html") + with open(template_path, "r", encoding="utf-8") as f: + html = f.read() + + # Insert static json data into the HTML + html = _re.sub(r"\{\{\s*url_for\('static', filename='([^']+)'\)\s*\}\}", r"static/\1", html) + script_tag_idx = html.find('