|
39 | 39 | scatterX: null, // 'hparams.lr' | 'summary.val_acc' | … |
40 | 40 | scatterY: null, |
41 | 41 | tableHideSame: false, |
| 42 | + combineSelection: new Set(), // metric names currently selected for combining |
| 43 | + combinedCharts: [], // [{id, metrics: string[]}] |
42 | 44 | }; |
43 | 45 |
|
44 | 46 | // setInterval handles for log auto-refresh; null when not running. |
|
116 | 118 | tableHideSame: state.tableHideSame, |
117 | 119 | activeTab: state.activeTab, |
118 | 120 | theme: state.theme, |
| 121 | + combinedCharts: state.combinedCharts, |
119 | 122 | }; |
120 | 123 | try { localStorage.setItem('spt-web-state', JSON.stringify(snap)); } catch {} |
121 | 124 | const ids = [...state.visible].map(id => encodeURIComponent(id)).join(','); |
|
162 | 165 | if (typeof saved.tableHideSame === 'boolean') state.tableHideSame = saved.tableHideSame; |
163 | 166 | if (typeof saved.activeTab === 'string') state.activeTab = saved.activeTab; |
164 | 167 | if (typeof saved.theme === 'string') state.theme = saved.theme; |
| 168 | + if (Array.isArray(saved.combinedCharts)) state.combinedCharts = saved.combinedCharts; |
165 | 169 | if (Array.isArray(saved.visible)) state._pendingVisible = new Set(saved.visible); |
166 | 170 | } |
167 | 171 |
|
|
1077 | 1081 | const mediaTags = visibleMediaTags(); |
1078 | 1082 | const allTags = [...new Set([...metrics, ...mediaTags.keys()])]; |
1079 | 1083 |
|
1080 | | - if (allTags.length === 0) { |
| 1084 | + if (allTags.length === 0 && state.combinedCharts.length === 0) { |
1081 | 1085 | for (const { plot } of state.charts.values()) plot?.destroy(); |
1082 | 1086 | state.charts.clear(); |
1083 | 1087 | for (const { panel } of state.mediaPanels.values()) panel.remove(); |
|
1088 | 1092 | return; |
1089 | 1093 | } |
1090 | 1094 |
|
1091 | | - // Tear down chart panels for metrics that vanished. |
| 1095 | + // Tear down chart panels for metrics that vanished (skip combined charts). |
1092 | 1096 | const metricSet = new Set(metrics); |
| 1097 | + let selectionChanged = false; |
1093 | 1098 | for (const [name, entry] of [...state.charts.entries()]) { |
| 1099 | + if (name.startsWith('__combined__/')) continue; |
1094 | 1100 | if (!metricSet.has(name) || mediaTags.has(name)) { |
1095 | 1101 | entry.plot?.destroy(); |
1096 | 1102 | entry.panel.remove(); |
1097 | 1103 | state.charts.delete(name); |
| 1104 | + if (state.combineSelection.delete(name)) selectionChanged = true; |
1098 | 1105 | } |
1099 | 1106 | } |
| 1107 | + if (selectionChanged) updateCombineSelectionUI(); |
1100 | 1108 | // Tear down media panels for tags that vanished. |
1101 | 1109 | for (const [tag, entry] of [...state.mediaPanels.entries()]) { |
1102 | 1110 | if (!mediaTags.has(tag)) { |
|
1105 | 1113 | } |
1106 | 1114 | } |
1107 | 1115 |
|
| 1116 | + // Combined charts section — pinned above the metric tree. |
| 1117 | + let combinedSection = root.querySelector(':scope > .combined-charts-section'); |
| 1118 | + if (!combinedSection) { |
| 1119 | + combinedSection = document.createElement('div'); |
| 1120 | + combinedSection.className = 'combined-charts-section'; |
| 1121 | + } |
| 1122 | + renderCombinedCharts(combinedSection); |
| 1123 | + |
1108 | 1124 | // Skip the full tree rebuild when nothing structurally changed. |
1109 | 1125 | // Streaming-metrics chunks fire scheduleRerender many times per |
1110 | 1126 | // second; re-parenting a playing ``<video>`` (or just shifting page |
|
1120 | 1136 | const container = document.createElement('div'); |
1121 | 1137 | container.className = 'metric-tree'; |
1122 | 1138 | attachMetricTree(container, tree, '', mediaTags); |
1123 | | - root.replaceChildren(container); |
| 1139 | + root.replaceChildren(combinedSection, container); |
1124 | 1140 | state._lastTreeKey = treeKey; |
1125 | 1141 |
|
1126 | 1142 | // After re-parenting, sizes may have changed. Resize each plot. |
|
1131 | 1147 | if (w) entry.plot.setSize({ width: w, height: 240 }); |
1132 | 1148 | } |
1133 | 1149 | } |
| 1150 | + } else if (root.firstChild !== combinedSection) { |
| 1151 | + root.prepend(combinedSection); |
1134 | 1152 | } |
1135 | 1153 |
|
1136 | 1154 | for (const name of metrics) updateChart(name); |
|
1360 | 1378 | span.textContent = displayName || fullName; |
1361 | 1379 | span.title = fullName; |
1362 | 1380 | title.appendChild(span); |
| 1381 | + const toggleBtn = document.createElement('button'); |
| 1382 | + toggleBtn.className = 'chart-combine-toggle icon-btn'; |
| 1383 | + toggleBtn.type = 'button'; |
| 1384 | + toggleBtn.title = 'select to combine into one chart'; |
| 1385 | + toggleBtn.textContent = '+'; |
| 1386 | + toggleBtn.addEventListener('click', (e) => { |
| 1387 | + e.stopPropagation(); |
| 1388 | + if (state.combineSelection.has(fullName)) { |
| 1389 | + state.combineSelection.delete(fullName); |
| 1390 | + } else { |
| 1391 | + state.combineSelection.add(fullName); |
| 1392 | + } |
| 1393 | + updateCombineSelectionUI(); |
| 1394 | + }); |
| 1395 | + title.appendChild(toggleBtn); |
1363 | 1396 | const dlBtn = document.createElement('button'); |
1364 | 1397 | dlBtn.className = 'chart-download-btn icon-btn'; |
1365 | 1398 | dlBtn.type = 'button'; |
|
1690 | 1723 | return n.includes('loss') || n.includes('err') || n.includes('perplexity') || n.includes('ppl'); |
1691 | 1724 | } |
1692 | 1725 |
|
| 1726 | + // ---- combine-metrics feature ------------------------------------------ |
| 1727 | + |
| 1728 | + function updateCombineSelectionUI() { |
| 1729 | + const btn = document.getElementById('combine-btn'); |
| 1730 | + if (!btn) return; |
| 1731 | + const n = state.combineSelection.size; |
| 1732 | + btn.textContent = `Combine (${n})`; |
| 1733 | + btn.hidden = n < 2; |
| 1734 | + for (const [name, entry] of state.charts.entries()) { |
| 1735 | + if (name.startsWith('__combined__/')) continue; |
| 1736 | + const toggleBtn = entry.panel.querySelector('.chart-combine-toggle'); |
| 1737 | + if (toggleBtn) toggleBtn.classList.toggle('active', state.combineSelection.has(name)); |
| 1738 | + } |
| 1739 | + } |
| 1740 | + |
| 1741 | + function buildCombinedChartData(metricNames, runIds) { |
| 1742 | + const series = []; |
| 1743 | + const xUnion = new Set(); |
| 1744 | + const multiRun = runIds.length > 1; |
| 1745 | + for (const metricName of metricNames) { |
| 1746 | + for (const id of runIds) { |
| 1747 | + const m = state.metrics.get(id); |
| 1748 | + if (!m || !m.metrics[metricName]) continue; |
| 1749 | + const col = m.metrics[metricName]; |
| 1750 | + const xs = col[state.xAxis]; |
| 1751 | + const fallback = (state.xAxis === 'epoch' && !xs?.some(v => v != null)) ? col.step : xs; |
| 1752 | + const xArr = fallback || col.step; |
| 1753 | + const filtered = { id, metricName, xs: [], ys: [] }; |
| 1754 | + for (let i = 0; i < col.y.length; i++) { |
| 1755 | + const x = xArr ? xArr[i] : i; |
| 1756 | + if (x == null || !isFinite(x)) continue; |
| 1757 | + filtered.xs.push(x); |
| 1758 | + filtered.ys.push(col.y[i]); |
| 1759 | + xUnion.add(x); |
| 1760 | + } |
| 1761 | + if (filtered.ys.length) series.push(filtered); |
| 1762 | + } |
| 1763 | + } |
| 1764 | + const xs = [...xUnion].sort((a, b) => a - b); |
| 1765 | + const xIdx = new Map(); |
| 1766 | + for (let i = 0; i < xs.length; i++) xIdx.set(xs[i], i); |
| 1767 | + const seriesCfg = [{}]; |
| 1768 | + const data = [xs]; |
| 1769 | + for (const s of series) { |
| 1770 | + const arr = new Array(xs.length).fill(null); |
| 1771 | + for (let i = 0; i < s.xs.length; i++) arr[xIdx.get(s.xs[i])] = s.ys[i]; |
| 1772 | + data.push(emaSmooth(arr, state.smoothing)); |
| 1773 | + const label = multiRun ? `${s.metricName} · ${s.id}` : s.metricName; |
| 1774 | + seriesCfg.push({ |
| 1775 | + label, |
| 1776 | + runId: label, |
| 1777 | + stroke: runColor(multiRun ? `${s.metricName}:${s.id}` : s.metricName), |
| 1778 | + width: 1.6, |
| 1779 | + points: { show: false }, |
| 1780 | + spanGaps: true, |
| 1781 | + }); |
| 1782 | + } |
| 1783 | + const seriesStats = series.map(s => { |
| 1784 | + const raw = s.ys.filter(v => v != null && isFinite(v)); |
| 1785 | + const lastVal = raw.length ? raw[raw.length - 1] : null; |
| 1786 | + const bestVal = raw.length ? Math.max(...raw) : null; |
| 1787 | + const label = multiRun ? `${s.metricName} · ${s.id}` : s.metricName; |
| 1788 | + return { id: label, lastVal, bestVal }; |
| 1789 | + }); |
| 1790 | + return { data, seriesCfg, seriesStats }; |
| 1791 | + } |
| 1792 | + |
| 1793 | + function makeCombinedPanel(combinedId, metricNames) { |
| 1794 | + const panel = document.createElement('div'); |
| 1795 | + panel.className = 'chart-panel combined-chart-panel'; |
| 1796 | + const title = document.createElement('div'); |
| 1797 | + title.className = 'chart-title'; |
| 1798 | + const span = document.createElement('span'); |
| 1799 | + span.className = 'name'; |
| 1800 | + const shortNames = metricNames.map(n => n.split('/').pop()); |
| 1801 | + span.textContent = 'Combined: ' + shortNames.join(', '); |
| 1802 | + span.title = metricNames.join(', '); |
| 1803 | + title.appendChild(span); |
| 1804 | + const removeBtn = document.createElement('button'); |
| 1805 | + removeBtn.className = 'chart-remove-btn icon-btn'; |
| 1806 | + removeBtn.type = 'button'; |
| 1807 | + removeBtn.title = 'remove combined chart'; |
| 1808 | + removeBtn.textContent = '×'; |
| 1809 | + removeBtn.addEventListener('click', () => { |
| 1810 | + state.combinedCharts = state.combinedCharts.filter(c => c.id !== combinedId); |
| 1811 | + const key = '__combined__/' + combinedId; |
| 1812 | + const entry = state.charts.get(key); |
| 1813 | + if (entry) { entry.plot?.destroy(); state.charts.delete(key); } |
| 1814 | + panel.remove(); |
| 1815 | + saveState(); |
| 1816 | + updateCombineSelectionUI(); |
| 1817 | + }); |
| 1818 | + title.appendChild(removeBtn); |
| 1819 | + const dlBtn = document.createElement('button'); |
| 1820 | + dlBtn.className = 'chart-download-btn icon-btn'; |
| 1821 | + dlBtn.type = 'button'; |
| 1822 | + dlBtn.title = 'download chart as PNG'; |
| 1823 | + dlBtn.textContent = '⬇'; |
| 1824 | + dlBtn.addEventListener('click', () => { |
| 1825 | + downloadChartPNG('__combined__/' + combinedId, 'Combined: ' + shortNames.join(', ')); |
| 1826 | + }); |
| 1827 | + title.appendChild(dlBtn); |
| 1828 | + const resetBtn = document.createElement('button'); |
| 1829 | + resetBtn.className = 'chart-zoom-reset'; |
| 1830 | + resetBtn.type = 'button'; |
| 1831 | + resetBtn.title = 'reset zoom'; |
| 1832 | + resetBtn.textContent = '⤢'; |
| 1833 | + resetBtn.hidden = true; |
| 1834 | + title.appendChild(resetBtn); |
| 1835 | + panel.appendChild(title); |
| 1836 | + const body = document.createElement('div'); |
| 1837 | + body.className = 'chart-body'; |
| 1838 | + panel.appendChild(body); |
| 1839 | + const annot = document.createElement('div'); |
| 1840 | + annot.className = 'chart-annot'; |
| 1841 | + panel.appendChild(annot); |
| 1842 | + return panel; |
| 1843 | + } |
| 1844 | + |
| 1845 | + function renderCombinedCharts(container) { |
| 1846 | + const visibleIds = effectivelyVisible().sort(); |
| 1847 | + const validIds = new Set(state.combinedCharts.map(c => c.id)); |
| 1848 | + for (const [key, entry] of [...state.charts.entries()]) { |
| 1849 | + if (!key.startsWith('__combined__/')) continue; |
| 1850 | + const id = key.slice('__combined__/'.length); |
| 1851 | + if (!validIds.has(id)) { |
| 1852 | + entry.plot?.destroy(); |
| 1853 | + entry.panel.remove(); |
| 1854 | + state.charts.delete(key); |
| 1855 | + } |
| 1856 | + } |
| 1857 | + for (const combined of state.combinedCharts) { |
| 1858 | + const key = '__combined__/' + combined.id; |
| 1859 | + let entry = state.charts.get(key); |
| 1860 | + if (!entry) { |
| 1861 | + const panel = makeCombinedPanel(combined.id, combined.metrics); |
| 1862 | + entry = { panel, plot: null, configKey: '' }; |
| 1863 | + state.charts.set(key, entry); |
| 1864 | + } |
| 1865 | + container.appendChild(entry.panel); |
| 1866 | + const { data, seriesCfg, seriesStats } = buildCombinedChartData(combined.metrics, visibleIds); |
| 1867 | + const configKey = JSON.stringify([visibleIds, combined.metrics, state.xAxis, state.logY, state.theme]); |
| 1868 | + if (seriesCfg.length <= 1) { |
| 1869 | + if (entry.plot) { entry.plot.destroy(); entry.plot = null; entry.configKey = ''; } |
| 1870 | + updateAnnotTable(key, entry, [], []); |
| 1871 | + continue; |
| 1872 | + } |
| 1873 | + if (entry.plot && entry.configKey === configKey) { |
| 1874 | + entry.plot.setData(data); |
| 1875 | + updateAnnotTable(key, entry, seriesCfg, seriesStats); |
| 1876 | + continue; |
| 1877 | + } |
| 1878 | + if (entry.plot) entry.plot.destroy(); |
| 1879 | + const body = entry.panel.querySelector('.chart-body'); |
| 1880 | + const resetBtn = entry.panel.querySelector('.chart-zoom-reset'); |
| 1881 | + if (resetBtn) resetBtn.hidden = true; |
| 1882 | + const width = body.clientWidth || 400; |
| 1883 | + entry.plot = new uPlot(makeUplotOpts(key, width, seriesCfg, resetBtn), data, body); |
| 1884 | + if (resetBtn) { |
| 1885 | + resetBtn.onclick = () => { |
| 1886 | + for (const [, e] of state.charts) { |
| 1887 | + if (!e.plot) continue; |
| 1888 | + const xData = e.plot.data[0]; |
| 1889 | + if (!xData || !xData.length) continue; |
| 1890 | + e.plot.setScale('x', { min: xData[0], max: xData[xData.length - 1] }); |
| 1891 | + } |
| 1892 | + }; |
| 1893 | + } |
| 1894 | + entry.configKey = configKey; |
| 1895 | + updateAnnotTable(key, entry, seriesCfg, seriesStats); |
| 1896 | + } |
| 1897 | + } |
| 1898 | + |
1693 | 1899 | function fmtTooltipNum(v) { |
1694 | 1900 | if (v == null || !isFinite(v)) return '—'; |
1695 | 1901 | if (Number.isInteger(v)) return String(v); |
|
3187 | 3393 |
|
3188 | 3394 | document.getElementById('export-csv').addEventListener('click', exportMetricsCSV); |
3189 | 3395 |
|
| 3396 | + document.getElementById('combine-btn').addEventListener('click', () => { |
| 3397 | + if (state.combineSelection.size < 2) return; |
| 3398 | + const id = String(Date.now()); |
| 3399 | + const metrics = [...state.combineSelection]; |
| 3400 | + state.combinedCharts.push({ id, metrics }); |
| 3401 | + state.combineSelection.clear(); |
| 3402 | + saveState(); |
| 3403 | + scheduleRerender(); |
| 3404 | + updateCombineSelectionUI(); |
| 3405 | + }); |
| 3406 | + |
3190 | 3407 | document.getElementById('add-filter-btn').addEventListener('click', () => openFilterDraft(null)); |
3191 | 3408 |
|
3192 | 3409 | document.getElementById('group-by').addEventListener('change', e => { |
|
0 commit comments