| <div class="d3-trackio"> |
| <div class="d3-trackio__grid"> |
| <div class="cell" data-metric="epoch" data-title="epoch"></div> |
| <div class="cell" data-metric="train_accuracy" data-title="train_accuracy"></div> |
| <div class="cell" data-metric="train_loss" data-title="train_loss"></div> |
| <div class="cell" data-metric="val_accuracy" data-title="val_accuracy"></div> |
| <div class="cell cell--wide" data-metric="val_loss" data-title="val_loss"></div> |
| </div> |
| <noscript>JavaScript is required to render this chart.</noscript> |
| </div> |
| <style> |
| .d3-trackio { position: relative; --z-tooltip: 50; --z-overlay: 99999999; |
| |
| --axis-color: rgba(0,0,0,.18); |
| --tick-color: rgba(0,0,0,.50); |
| --grid-color: rgba(0,0,0,.05); |
| } |
| |
| [data-theme="dark"] .d3-trackio { |
| --axis-color: rgba(255,255,255,.18); |
| --tick-color: rgba(255,255,255,.50); |
| --grid-color: rgba(255,255,255,.05); |
| } |
| .d3-trackio__grid { |
| display: grid; |
| grid-template-columns: repeat(2, minmax(0, 1fr)); |
| gap: 12px; |
| } |
| @media (max-width: 980px) { |
| .d3-trackio__grid { grid-template-columns: 1fr; } |
| } |
| .d3-trackio__grid .cell--wide { grid-column: 1 / -1; } |
| |
| .d3-trackio .cell { |
| border: 1px solid var(--border-color); |
| border-radius: 10px; |
| background: var(--surface-bg); |
| display: flex; |
| flex-direction: column; |
| position: relative; |
| } |
| .d3-trackio .cell-header { |
| padding: 8px 10px; |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| gap: 8px; |
| } |
| .d3-trackio .cell-title { |
| font-size: 13px; |
| font-weight: 700; |
| color: var(--text-color); |
| text-transform: none; |
| } |
| .d3-trackio .cell-action { |
| margin-left: auto; |
| display: inline-flex; |
| align-items: center; |
| justify-content: center; |
| width: 36px; |
| height: 36px; |
| border: 0; |
| background: transparent; |
| color: var(--text-color); |
| opacity: .8; |
| cursor: pointer; |
| } |
| .d3-trackio .cell-action:hover { opacity: 1; } |
| .d3-trackio .cell-action svg { width: 28px; height: 28px; } |
| .d3-trackio .cell-action svg, .d3-trackio .cell-action svg path { fill: var(--text-color); stroke: none; } |
| |
| |
| .d3-trackio__overlay { |
| position: fixed; |
| inset: 0; |
| background: rgba(0,0,0,.48); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| z-index: var(--z-overlay); |
| opacity: 0; |
| pointer-events: none; |
| transition: opacity .2s ease; |
| } |
| .d3-trackio__overlay.is-open { opacity: 1; pointer-events: auto; } |
| .d3-trackio__modal { |
| position: relative; |
| width: min(92vw, 1200px); |
| height: min(92vh, 900px); |
| display: flex; |
| align-items: stretch; |
| justify-content: stretch; |
| transform: scale(.96); |
| transition: transform .2s ease; |
| } |
| .d3-trackio__overlay.is-open .d3-trackio__modal { transform: scale(1); } |
| .d3-trackio__modal .cell { width: 100%; height: 100%; } |
| .d3-trackio__modal .cell .cell-body { height: calc(100% - 44px); } |
| |
| .d3-trackio__modal .cell .cell-body svg { width: 100% !important; height: auto !important; } |
| .d3-trackio__modal .cell .cell-action { display: none; } |
| .d3-trackio__modal-close { |
| position: absolute; |
| top: 10px; |
| right: 10px; |
| background: var(--surface-bg); |
| border: 1px solid var(--border-color); |
| color: var(--text-color); |
| width: 28px; |
| height: 28px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| cursor: pointer; |
| } |
| .d3-trackio .cell-body { position: relative; width: 100%; overflow: hidden; } |
| .d3-trackio .cell-body svg { max-width: 100%; height: auto; display: block; } |
| |
| |
| .d3-trackio .axes path, |
| .d3-trackio .axes line { stroke: var(--axis-color); } |
| .d3-trackio .axes text { fill: var(--tick-color); } |
| .d3-trackio .grid line { stroke: var(--grid-color); } |
| |
| |
| .d3-trackio__header { |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| gap: 12px; |
| margin: 0 0 10px 0; |
| flex-wrap: wrap; |
| width: 100%; |
| } |
| .d3-trackio__header .legend-bottom { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: 6px; |
| font-size: 12px; |
| color: var(--text-color); |
| text-align: center; |
| } |
| .d3-trackio__header .legend-bottom .legend-title { font-size: 12px; font-weight: 700; color: var(--text-color); } |
| .d3-trackio__header .legend-bottom .items { display: flex; flex-wrap: wrap; gap: 8px 14px; justify-content: center; align-items: center; } |
| .d3-trackio__header .legend-bottom .item { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; } |
| .d3-trackio__header .legend-bottom .swatch { width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--border-color); display: inline-block; } |
| |
| |
| .d3-trackio.hovering .lines path.ghost { opacity: .25; } |
| .d3-trackio.hovering .points circle.ghost { opacity: .25; } |
| .d3-trackio.hovering .areas path.ghost { opacity: .08; } |
| .d3-trackio.hovering .legend-bottom .item.ghost { opacity: .35; } |
| |
| |
| .d3-trackio .d3-tooltip { |
| z-index: var(--z-tooltip); |
| backdrop-filter: saturate(1.12) blur(8px); |
| } |
| .d3-trackio .d3-tooltip__inner { display: flex; flex-direction: column; gap: 6px; min-width: 220px; } |
| .d3-trackio .d3-tooltip__inner > div:first-child { font-weight: 800; letter-spacing: 0.1px; margin-bottom: 0; } |
| .d3-trackio .d3-tooltip__inner > div:nth-child(2) { font-size: 11px; color: var(--muted-color); display: block; margin-top: -4px; margin-bottom: 2px; letter-spacing: 0.1px; } |
| .d3-trackio .d3-tooltip__inner > div:nth-child(n+3) { padding-top: 6px; border-top: 1px solid var(--border-color); } |
| .d3-trackio .d3-tooltip__color-dot { display: inline-block; width: 12px; height: 12px; border-radius: 3px; border: 1px solid var(--border-color); } |
| </style> |
| <script> |
| (() => { |
| const THIS_SCRIPT = document.currentScript; |
| |
| const TARGET_METRICS = [ |
| 'epoch', |
| 'train_accuracy', |
| 'train_loss', |
| 'val_accuracy', |
| 'val_loss' |
| ]; |
| |
| const prettyMetricLabel = (key) => { |
| if (!key) return ''; |
| const table = { |
| 'train_accuracy': 'Train Accuracy', |
| 'val_accuracy': 'Val Accuracy', |
| 'train_loss': 'Train Loss', |
| 'val_loss': 'Val Loss', |
| 'epoch': 'Epoch' |
| }; |
| if (table[key]) return table[key]; |
| const cleaned = String(key).replace(/[_-]+/g, ' ').trim(); |
| return cleaned.split(/\s+/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); |
| }; |
| |
| const ensureD3 = (cb) => { |
| if (window.d3 && typeof window.d3.select === 'function') return cb(); |
| let s = document.getElementById('d3-cdn-script'); |
| if (!s) { s = document.createElement('script'); s.id = 'd3-cdn-script'; s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'; document.head.appendChild(s); } |
| const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); }; |
| s.addEventListener('load', onReady, { once: true }); if (window.d3) onReady(); |
| }; |
| |
| |
| function initCell(cell) { |
| const d3 = window.d3; |
| const metricKey = cell.getAttribute('data-metric'); |
| const titleText = cell.getAttribute('data-title') || metricKey; |
| const host = cell.closest('.d3-trackio'); |
| const shouldSuppress = () => !!(host && host.dataset && host.dataset.suppressTransitions === '1'); |
| |
| |
| const header = document.createElement('div'); header.className = 'cell-header'; |
| const title = document.createElement('div'); title.className = 'cell-title'; title.textContent = prettyMetricLabel(titleText); header.appendChild(title); |
| cell.appendChild(header); |
| |
| |
| const body = document.createElement('div'); body.className = 'cell-body'; cell.appendChild(body); |
| const svg = d3.select(body).append('svg').attr('width','100%').style('display','block'); |
| const gRoot = svg.append('g'); |
| const gGrid = gRoot.append('g').attr('class','grid'); |
| const gAxes = gRoot.append('g').attr('class','axes'); |
| const gAreas = gRoot.append('g').attr('class','areas'); |
| const gLines = gRoot.append('g').attr('class','lines'); |
| const gPoints = gRoot.append('g').attr('class','points'); |
| const gHover = gRoot.append('g').attr('class','hover'); |
| |
| |
| let suppressTransitions = false; |
| |
| |
| cell.style.position = cell.style.position || 'relative'; |
| let tip = cell.querySelector('.d3-tooltip'); let tipInner; let hideTipTimer = null; |
| if (!tip) { |
| tip = document.createElement('div'); tip.className = 'd3-tooltip'; |
| Object.assign(tip.style, { |
| position:'absolute', top:'0', left:'0', transform:'translate(-9999px,-9999px)', pointerEvents:'none', |
| padding:'10px 12px', borderRadius:'12px', fontSize:'12px', lineHeight:'1.35', border:'1px solid var(--border-color)', |
| background:'var(--surface-bg)', color:'var(--text-color)', boxShadow:'0 8px 32px rgba(0,0,0,.28), 0 2px 8px rgba(0,0,0,.12)', opacity:'0', transition:'opacity .12s ease' |
| }); |
| tipInner = document.createElement('div'); tipInner.className = 'd3-tooltip__inner'; tipInner.style.textAlign='left'; tip.appendChild(tipInner); cell.appendChild(tip); |
| } else { tipInner = tip.querySelector('.d3-tooltip__inner') || tip; } |
| |
| |
| let width = 800, height = 10; const margin = { top: 10, right: 20, bottom: 46, left: 44 }; |
| const xScale = d3.scaleLinear(); |
| const yScale = d3.scaleLinear(); |
| const lineGen = d3.line().x(d => xScale(d.step)).y(d => yScale(d.value)); |
| |
| function updateLayout(axisLabelY, xTicksArg){ |
| const rect = cell.getBoundingClientRect(); |
| width = Math.max(1, Math.round(rect && rect.width ? rect.width : (cell.clientWidth || 800))); |
| |
| height = Number(cell.getAttribute('data-height')) || 200; |
| svg.attr('width', width).attr('height', height).attr('viewBox', `0 0 ${width} ${height}`).attr('preserveAspectRatio','xMidYMid meet'); |
| const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom; |
| gRoot.attr('transform', `translate(${margin.left},${margin.top})`); |
| xScale.range([0, innerWidth]); yScale.range([innerHeight, 0]); |
| |
| |
| gGrid.selectAll('*').remove(); |
| gGrid.selectAll('line').data(yScale.ticks(6)).join('line') |
| .attr('x1',0).attr('x2',innerWidth).attr('y1',d=>yScale(d)).attr('y2',d=>yScale(d)) |
| .attr('stroke','var(--grid-color)').attr('stroke-width',1).attr('shape-rendering','crispEdges'); |
| |
| |
| gAxes.selectAll('*').remove(); |
| |
| |
| |
| const makeTicks = (scale, approx) => { |
| const arr = scale.ticks(approx); |
| const dom = scale.domain(); |
| if (arr.length === 0 || arr[0] !== dom[0]) arr.unshift(dom[0]); |
| if (arr[arr.length - 1] !== dom[dom.length - 1]) arr.push(dom[dom.length - 1]); |
| return Array.from(new Set(arr)); |
| }; |
| const maxXTicks = Math.max(3, Math.min(12, Math.floor(innerWidth / 90))); |
| let xTicksForced = (Array.isArray(xTicksArg) && xTicksArg.length) ? [] : makeTicks(xScale, 8); |
| if (Array.isArray(xTicksArg) && xTicksArg.length) { |
| const realMin = xTicksArg[0]; |
| const realMax = xTicksArg[xTicksArg.length - 1]; |
| const niceVals = d3.ticks(realMin, realMax, maxXTicks); |
| const nearestIndex = (val) => { |
| let bestIdx = 0; let bestDist = Infinity; |
| for (let i = 0; i < xTicksArg.length; i++) { |
| const d = Math.abs(xTicksArg[i] - val); |
| if (d < bestDist) { bestDist = d; bestIdx = i; } |
| } |
| return bestIdx; |
| }; |
| const indices = Array.from(new Set(niceVals.map(v => nearestIndex(v)))).sort((a,b)=>a-b); |
| if (indices[0] !== 0) indices.unshift(0); |
| const lastIdx = xTicksArg.length - 1; |
| if (indices[indices.length - 1] !== lastIdx) indices.push(lastIdx); |
| xTicksForced = indices; |
| } else if (xTicksForced.length > maxXTicks) { |
| const stride = Math.max(1, Math.ceil(xTicksForced.length / maxXTicks)); |
| const last = xTicksForced[xTicksForced.length - 1]; |
| xTicksForced = xTicksForced.filter((_, idx) => (idx % stride) === 0); |
| if (xTicksForced[xTicksForced.length - 1] !== last) xTicksForced.push(last); |
| } |
| const maxYTicks = Math.max(5, Math.min(6, Math.floor(innerHeight / 60))); |
| const yCount = maxYTicks; |
| const yDom = yScale.domain(); |
| const yTicksForced = (yCount <= 2) |
| ? [yDom[0], yDom[1]] |
| : Array.from({ length: yCount }, (_, i) => yDom[0] + ((yDom[1] - yDom[0]) * (i / (yCount - 1)))); |
| |
| gAxes.append('g') |
| .attr('transform', `translate(0,${innerHeight})`) |
| .call( |
| d3.axisBottom(xScale) |
| .tickValues(xTicksForced) |
| .tickFormat((i) => { |
| |
| const val = (Array.isArray(xTicksArg) && xTicksArg[i] != null ? xTicksArg[i] : i); |
| return formatAbbrev(val); |
| }) |
| ) |
| .call(g=>{ g.selectAll('path, line').attr('stroke','var(--axis-color)'); g.selectAll('text').attr('fill','var(--tick-color)').style('font-size','11px'); }); |
| |
| gAxes.append('g') |
| .call(d3.axisLeft(yScale).tickValues(yTicksForced).tickFormat((v)=>formatAbbrev(v))) |
| .call(g=>{ g.selectAll('path, line').attr('stroke','var(--axis-color)'); g.selectAll('text').attr('fill','var(--tick-color)').style('font-size','11px'); }); |
| |
| |
| gAxes.append('text') |
| .attr('class', 'x-axis-label') |
| .attr('x', innerWidth / 2) |
| .attr('y', innerHeight + Math.max(20, Math.min(36, margin.bottom - 12))) |
| .attr('fill', 'var(--text-color)') |
| .attr('text-anchor', 'middle') |
| .style('font-size', '8px') |
| .style('opacity', '.5') |
| .style('letter-spacing', '.5px') |
| .style('text-transform', 'uppercase') |
| .style('font-weight', '500') |
| .text('Steps'); |
| |
| return { innerWidth, innerHeight, xTicksForced, yTicksForced }; |
| } |
| |
| |
| const formatAbbrev = (value) => { |
| const num = Number(value); |
| if (!Number.isFinite(num)) return String(value); |
| const abs = Math.abs(num); |
| const trim2 = (n) => Number(n).toFixed(2).replace(/\.?0+$/, ''); |
| if (abs >= 1e9) return `${trim2(num / 1e9)}B`; |
| if (abs >= 1e6) return `${trim2(num / 1e6)}M`; |
| if (abs >= 1e3) return `${trim2(num / 1e3)}K`; |
| return trim2(num); |
| }; |
| |
| function render(metricData, colorForRun) { |
| const runs = Object.keys(metricData || {}); |
| const hasAny = runs.some(r => (metricData[r]||[]).length > 0); |
| if (!hasAny) { |
| |
| gRoot.style('display', 'none'); |
| let msg = body.querySelector('.empty-msg'); |
| if (!msg) { |
| msg = document.createElement('div'); |
| msg.className = 'empty-msg'; |
| msg.textContent = 'Metric not found in data.'; |
| Object.assign(msg.style, { padding:'10px', fontSize:'12px', color:'var(--muted-color)' }); |
| body.appendChild(msg); |
| } |
| return; |
| } |
| |
| const msg = body.querySelector('.empty-msg'); if (msg) msg.remove(); |
| gRoot.style('display', null); |
| |
| let minStep = Infinity, maxStep = -Infinity, minVal = Infinity, maxVal = -Infinity; |
| runs.forEach(r => { (metricData[r]||[]).forEach(pt => { minStep = Math.min(minStep, pt.step); maxStep = Math.max(maxStep, pt.step); minVal = Math.min(minVal, pt.value); maxVal = Math.max(maxVal, pt.value); }); }); |
| const isAccuracy = /accuracy/i.test(metricKey); |
| const axisLabelY = prettyMetricLabel(metricKey); |
| if (isAccuracy) yScale.domain([0, 1]).nice(); else yScale.domain([minVal, maxVal]).nice(); |
| |
| const stepSet = new Set(); runs.forEach(r => (metricData[r]||[]).forEach(v => stepSet.add(v.step))); |
| const hoverSteps = Array.from(stepSet).sort((a,b)=>a-b); |
| const stepIndex = new Map(hoverSteps.map((s,i)=>[s,i])); |
| xScale.domain([0, Math.max(0, hoverSteps.length - 1)]); |
| |
| |
| lineGen.x(d => xScale(stepIndex.get(d.step))); |
| |
| const { innerWidth, innerHeight, xTicksForced } = updateLayout(axisLabelY, hoverSteps); |
| |
| |
| gGrid.selectAll('line.vstep') |
| .data(xTicksForced) |
| .join( |
| enter => enter.append('line').attr('class','vstep') |
| .attr('y1', 0).attr('y2', innerHeight) |
| .attr('x1', d => xScale(d)).attr('x2', d => xScale(d)) |
| .attr('stroke','var(--grid-color)').attr('stroke-width',1).attr('shape-rendering','crispEdges'), |
| update => update |
| .attr('y1', 0).attr('y2', innerHeight) |
| .attr('x1', d => xScale(d)).attr('x2', d => xScale(d)), |
| exit => exit.remove() |
| ); |
| |
| |
| gAreas.selectAll('*').remove(); |
| |
| |
| const series = runs.map(r => ({ run: r, color: colorForRun(r), values: (metricData[r]||[]).slice().sort((a,b)=>a.step-b.step) })); |
| const paths = gLines.selectAll('path.run-line').data(series, d=>d.run); |
| paths.enter().append('path').attr('class','run-line').attr('data-run', d=>d.run).attr('fill','none').attr('stroke-width', 1.5).attr('opacity', 0.9) |
| .attr('stroke', d=>d.color).attr('d', d=>lineGen(d.values)); |
| if (shouldSuppress()) { |
| paths.attr('stroke', d=>d.color).attr('opacity',0.9).attr('d', d=>lineGen(d.values)); |
| } else { |
| paths.transition().duration(260).attr('stroke', d=>d.color).attr('opacity',0.9).attr('d', d=>lineGen(d.values)); |
| } |
| paths.exit().remove(); |
| |
| |
| const allPoints = series.flatMap(s => s.values.map(v => ({ run:s.run, color:s.color, step:v.step, value:v.value }))); |
| const ptsSel = gPoints.selectAll('circle.pt').data(allPoints, d=> `${d.run}-${d.step}`); |
| ptsSel.enter().append('circle').attr('class','pt').attr('data-run', d=>d.run).attr('r', 0).attr('fill', d=>d.color).attr('fill-opacity', 0.6) |
| .attr('stroke', 'none').attr('cx', d=>xScale(stepIndex.get(d.step))).attr('cy', d=>yScale(d.value)) |
| .merge(ptsSel) |
| .each(function(d){ }) |
| .attr('cx', d=>xScale(stepIndex.get(d.step))).attr('cy', d=>yScale(d.value)); |
| if (!shouldSuppress()) { |
| try { gPoints.selectAll('circle.pt').transition().duration(150).attr('cx', d=>xScale(stepIndex.get(d.step))).attr('cy', d=>yScale(d.value)); } catch(_){} |
| } |
| ptsSel.exit().remove(); |
| |
| |
| gHover.selectAll('*').remove(); |
| const overlay = gHover.append('rect').attr('fill','transparent').style('cursor','crosshair').attr('x',0).attr('y',0).attr('width', innerWidth).attr('height', innerHeight); |
| const hoverLine = gHover.append('line').style('stroke','var(--text-color)').attr('stroke-opacity', 0.25).attr('stroke-width',1).attr('y1',0).attr('y2',innerHeight).style('display','none'); |
| |
| |
| cell.__showExternalStep = (stepVal) => { |
| if (stepVal == null) { hoverLine.style('display','none'); return; } |
| const idx = stepIndex.get(stepVal); |
| if (idx == null) { hoverLine.style('display','none'); return; } |
| const xpx = xScale(idx); |
| hoverLine.attr('x1',xpx).attr('x2',xpx).style('display',null); |
| }; |
| cell.__clearExternalStep = () => { hoverLine.style('display','none'); }; |
| if (!cell.__syncAttached && host) { |
| host.addEventListener('trackio-hover-step', (ev) => { const d = ev && ev.detail; if (!d) return; if (cell.__showExternalStep) cell.__showExternalStep(d.step); }); |
| host.addEventListener('trackio-hover-clear', () => { if (cell.__clearExternalStep) cell.__clearExternalStep(); }); |
| cell.__syncAttached = true; |
| } |
| function onMove(ev){ if (hideTipTimer) { clearTimeout(hideTipTimer); hideTipTimer = null; } const [mx,my]=d3.pointer(ev, overlay.node()); const idx = Math.round(Math.max(0, Math.min(hoverSteps.length-1, xScale.invert(mx)))); const nearest = hoverSteps[idx]; const xpx = xScale(idx); hoverLine.attr('x1',xpx).attr('x2',xpx).style('display',null); if (host) { try { host.dispatchEvent(new CustomEvent('trackio-hover-step', { detail: { step: nearest } })); } catch(_){} } |
| let html = `<div><strong>step</strong> ${formatAbbrev(nearest)}</div><div><strong>${prettyMetricLabel(metricKey)}</strong></div>`; |
| const entries = series.map(s=>{ const map = new Map(s.values.map(v=>[v.step, v])); const pt = map.get(nearest); return { run:s.run, color:s.color, pt }; }).filter(e => e.pt && e.pt.value!=null); |
| entries.sort((a,b)=> (a.pt.value - b.pt.value)); |
| const fmt = (vv)=> (isAccuracy? (+vv).toFixed(4) : (+vv).toFixed(4)); |
| entries.forEach(e => { |
| html += `<div style=\"display:flex;align-items:center;gap:8px;white-space:nowrap;\"><span class=\"d3-tooltip__color-dot\" style=\"background:${e.color}\"></span><strong>${e.run}</strong><span style=\"margin-left:auto;text-align:right;\">${fmt(e.pt.value)}</span></div>`; |
| }); |
| tipInner.innerHTML = html; const offsetX=12, offsetY=12; tip.style.opacity='1'; tip.style.transform=`translate(${Math.round(mx+offsetX+margin.left)}px, ${Math.round(my+offsetY+margin.top)}px)`; |
| |
| try { |
| const sel = gPoints.selectAll('circle.pt'); |
| if (shouldSuppress()) { sel.interrupt().attr('r', d => (d && d.step === nearest ? 4 : 0)); } |
| else { sel.transition().duration(140).ease(d3.easeCubicOut).attr('r', d => (d && d.step === nearest ? 4 : 0)); } |
| } catch(_) {} |
| } |
| function onLeave(){ hideTipTimer = setTimeout(()=>{ tip.style.opacity='0'; tip.style.transform='translate(-9999px, -9999px)'; hoverLine.style('display','none'); if (host) { try { host.dispatchEvent(new CustomEvent('trackio-hover-clear')); } catch(_){} } try { const sel = gPoints.selectAll('circle.pt'); if (shouldSuppress()) { sel.interrupt().attr('r', 0); } else { sel.transition().duration(150).ease(d3.easeCubicOut).attr('r', 0); } } catch(_) {} }, 100); } |
| overlay.on('mousemove', onMove).on('mouseleave', onLeave); |
| } |
| |
| |
| const fsBtn = document.createElement('button'); fsBtn.className = 'cell-action cell-action--fullscreen'; fsBtn.type = 'button'; fsBtn.title='Fullscreen'; fsBtn.setAttribute('aria-label','Open fullscreen'); |
| fsBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M4 9V4h5v2H6v3H4zm10-5h5v5h-2V6h-3V4zM6 18h3v2H4v-5h2v3zm12-3h2v5h-5v-2h3v-3z"/></svg>'; |
| header.appendChild(fsBtn); |
| |
| function ensureOverlay(host){ |
| let overlay = host.querySelector('.d3-trackio__overlay'); |
| if (!overlay) { |
| overlay = document.createElement('div'); overlay.className='d3-trackio__overlay'; |
| const modal = document.createElement('div'); modal.className='d3-trackio__modal'; |
| const close = document.createElement('button'); close.className='d3-trackio__modal-close'; close.type='button'; close.innerHTML='✕'; |
| overlay.appendChild(modal); overlay.appendChild(close); |
| host.appendChild(overlay); |
| overlay.addEventListener('click', (e)=>{ if (e.target === overlay) close.click(); }); |
| close.addEventListener('click', ()=>{ |
| const moving = modal.querySelector('.cell'); if (!moving) { overlay.classList.remove('is-open'); return; } |
| const placeholder = moving.__placeholder; |
| if (!placeholder) { overlay.classList.remove('is-open'); return; } |
| |
| host.dataset.suppressTransitions = '1'; |
| |
| try { d3.select(host).selectAll('path.run-line').interrupt(); d3.select(host).selectAll('circle.pt').interrupt(); } catch(_){ } |
| |
| const from = moving.getBoundingClientRect(); |
| const to = placeholder.getBoundingClientRect(); |
| const dx = to.left - from.left; const dy = to.top - from.top; |
| const sx = Math.max(0.0001, to.width / from.width); |
| const sy = Math.max(0.0001, to.height / from.height); |
| moving.style.transformOrigin = 'top left'; |
| moving.style.transition = 'transform 220ms cubic-bezier(.2,.8,.2,1)'; |
| moving.style.transform = `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`; |
| const onEnd = () => { |
| moving.removeEventListener('transitionend', onEnd); |
| overlay.classList.remove('is-open'); |
| moving.style.transition = ''; |
| moving.style.transform = ''; |
| if (placeholder && placeholder.parentNode) { placeholder.parentNode.insertBefore(moving, placeholder); placeholder.remove(); } |
| moving.removeAttribute('data-height'); |
| |
| try { const h = host; if (h && h.__rerender) h.__rerender(); } catch {} |
| requestAnimationFrame(()=>{ delete host.dataset.suppressTransitions; }); |
| }; |
| moving.addEventListener('transitionend', onEnd); |
| }); |
| window.addEventListener('keydown', (e)=>{ if (e.key === 'Escape' && overlay.classList.contains('is-open')) { const btn = overlay.querySelector('.d3-trackio__modal-close'); btn && btn.click(); }}); |
| } |
| return overlay; |
| } |
| |
| fsBtn.addEventListener('click', ()=>{ |
| const hostNode = cell.closest('.d3-trackio'); if (!hostNode) return; |
| const overlay = ensureOverlay(hostNode); const modal = overlay.querySelector('.d3-trackio__modal'); |
| |
| const existing = modal.querySelector('.cell'); |
| if (overlay.classList.contains('is-open') && existing && existing !== cell) { |
| const btn = overlay.querySelector('.d3-trackio__modal-close'); |
| if (btn) { btn.dispatchEvent(new Event('click')); } |
| const waitEnd = () => { |
| if (!overlay.classList.contains('is-open')) { fsBtn.click(); return; } |
| requestAnimationFrame(waitEnd); |
| }; |
| waitEnd(); |
| return; |
| } |
| |
| hostNode.dataset.suppressTransitions = '1'; |
| |
| try { d3.select(hostNode).selectAll('path.run-line').interrupt(); d3.select(hostNode).selectAll('circle.pt').interrupt(); } catch(_){ } |
| const before = cell.getBoundingClientRect(); |
| const placeholder = document.createElement('div'); placeholder.style.width = cell.offsetWidth + 'px'; placeholder.style.height = cell.offsetHeight + 'px'; |
| cell.__placeholder = placeholder; cell.parentNode.insertBefore(placeholder, cell); |
| modal.appendChild(cell); |
| |
| const after = cell.getBoundingClientRect(); |
| const dx = before.left - after.left; const dy = before.top - after.top; |
| const sx = Math.max(0.0001, before.width / after.width); |
| const sy = Math.max(0.0001, before.height / after.height); |
| cell.style.transformOrigin = 'top left'; |
| cell.style.transition = 'transform 220ms cubic-bezier(.2,.8,.2,1)'; |
| cell.style.transform = `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`; |
| overlay.classList.add('is-open'); |
| requestAnimationFrame(()=>{ requestAnimationFrame(()=>{ cell.style.transform = 'none'; }); }); |
| const onEnd = () => { cell.removeEventListener('transitionend', onEnd); cell.style.transition = ''; cell.style.transform = ''; requestAnimationFrame(()=>{ }); }; |
| cell.addEventListener('transitionend', onEnd); |
| }); |
| |
| return { metricKey, render }; |
| } |
| |
| const bootstrap = () => { |
| const scriptEl = THIS_SCRIPT; |
| |
| let host = null; |
| if (scriptEl && scriptEl.parentElement && scriptEl.parentElement.querySelector) { |
| host = scriptEl.parentElement.querySelector('.d3-trackio'); |
| } |
| if (!host) { |
| let sib = scriptEl && scriptEl.previousElementSibling; |
| while (sib && !(sib.classList && sib.classList.contains('d3-trackio'))) { sib = sib.previousElementSibling; } |
| host = sib || null; |
| } |
| if (!host) { host = document.querySelector('.d3-trackio'); } |
| if (!host) return; |
| if (host.dataset && host.dataset.mounted === 'true') return; if (host.dataset) host.dataset.mounted = 'true'; |
| |
| |
| const header = document.createElement('div'); header.className = 'd3-trackio__header'; |
| const legend = document.createElement('div'); legend.className = 'legend-bottom'; legend.innerHTML = '<div class="legend-title">Runs</div><div class="items"></div>'; |
| header.appendChild(legend); |
| const gridNode = host.querySelector('.d3-trackio__grid'); |
| if (gridNode && gridNode.parentNode === host) { host.insertBefore(header, gridNode); } else { host.insertBefore(header, host.firstChild); } |
| |
| const cells = Array.from(host.querySelectorAll('.cell')); |
| if (!cells.length) return; |
| |
| const instances = cells.map(cell => initCell(cell)); |
| |
| |
| let mountEl = host; |
| while (mountEl && !mountEl.getAttribute?.('data-datafiles') && !mountEl.getAttribute?.('data-config')) { |
| mountEl = mountEl.parentElement; |
| } |
| let providedData = null; let providedConfig = null; |
| try { const attr = mountEl && mountEl.getAttribute ? mountEl.getAttribute('data-datafiles') : null; if (attr && attr.trim()) { providedData = attr.trim().startsWith('[') ? JSON.parse(attr) : attr.trim(); } } catch(_) {} |
| try { const cfg = mountEl && mountEl.getAttribute ? mountEl.getAttribute('data-config') : null; if (cfg && cfg.trim()) { providedConfig = cfg.trim().startsWith('{') ? JSON.parse(cfg) : cfg; } } catch(_) {} |
| |
| const DEFAULT_CSV = '/data/trackio_wandb_demo.csv'; |
| const ensureDataPrefix = (p) => { if (typeof p !== 'string' || !p) return p; return p.includes('/') ? p : `/data/${p}`; }; |
| const normalizeInput = (inp) => Array.isArray(inp) ? inp.map(ensureDataPrefix) : (typeof inp === 'string' ? [ ensureDataPrefix(inp) ] : null); |
| const CSV_PATHS = Array.isArray(providedData) |
| ? normalizeInput(providedData) |
| : (typeof providedData === 'string' ? normalizeInput(providedData) || [DEFAULT_CSV] : [ |
| DEFAULT_CSV, |
| './assets/data/formatting_filters.csv', |
| '../assets/data/formatting_filters.csv', |
| '../../assets/data/formatting_filters.csv' |
| ]); |
| const fetchFirstAvailable = async (paths) => { for (const p of paths) { try { const r = await fetch(p, { cache: 'no-cache' }); if (r.ok) return await r.text(); } catch(_){} } throw new Error('CSV not found'); }; |
| |
| const d3 = window.d3; |
| (async () => { |
| try { |
| |
| let textAll = ''; |
| if (Array.isArray(CSV_PATHS) && CSV_PATHS.length > 1) { |
| const texts = await Promise.all(CSV_PATHS.map(p => fetch(p, { cache:'no-cache' }).then(r => r.ok ? r.text() : '').catch(()=>''))); |
| textAll = texts.filter(Boolean).join('\n'); |
| } else { |
| textAll = await fetchFirstAvailable(CSV_PATHS); |
| } |
| const rows = d3.csvParse(textAll, d => ({ |
| run:(d.run||'').trim(), |
| step:+d.step, |
| metric:(d.metric||'').trim(), |
| value:+d.value, |
| stderr: (d.stderr!=null && d.stderr!=='') ? +d.stderr : null |
| })); |
| |
| |
| const metricsInData = Array.from(new Set(rows.map(r => r.metric))); |
| const lcSet = new Set(metricsInData.map(m => m.toLowerCase())); |
| const preferIfExists = (cand) => cand.find(c => lcSet.has(String(c).toLowerCase())) || null; |
| const resolveMetric = (target) => { |
| |
| const override = providedConfig && providedConfig.metricMap && providedConfig.metricMap[target]; |
| if (override && lcSet.has(String(override).toLowerCase())) return metricsInData.find(m => m.toLowerCase() === String(override).toLowerCase()); |
| |
| const exact = metricsInData.find(m => m.toLowerCase() === target.toLowerCase()); if (exact) return exact; |
| |
| const cands = (name) => metricsInData.filter(m => m.toLowerCase().includes(name)); |
| if (target === 'epoch') return preferIfExists(['epoch']); |
| if (target === 'train_accuracy') return preferIfExists([ |
| 'train_accuracy','training_accuracy','accuracy_train','train_acc','acc_train','train/accuracy','accuracy' |
| ]) || cands('acc').find(m => /train|trn/i.test(m)); |
| if (target === 'val_accuracy') return preferIfExists([ |
| 'val_accuracy','valid_accuracy','validation_accuracy','val_acc','acc_val','val/accuracy' |
| ]) || cands('acc').find(m => /val|valid/i.test(m)); |
| if (target === 'train_loss') return preferIfExists([ |
| 'train_loss','training_loss','loss_train','train/loss','loss' |
| ]) || cands('loss').find(m => /train|trn/i.test(m)); |
| if (target === 'val_loss') return preferIfExists([ |
| 'val_loss','validation_loss','valid_loss','loss_val','val/loss' |
| ]) || cands('loss').find(m => /val|valid/i.test(m)); |
| return null; |
| }; |
| const TARGET_TO_DATA = Object.fromEntries(TARGET_METRICS.map(t => [t, resolveMetric(t)])); |
| const metricsToDraw = TARGET_METRICS.filter(t => !!TARGET_TO_DATA[t]); |
| |
| |
| const runList = Array.from(new Set(rows.map(r => String(r.run||'').trim()).filter(Boolean))).sort(); |
| let currentRunList = runList.slice(); |
| let palette = null; |
| try { if (window.ColorPalettes && typeof window.ColorPalettes.getColors==='function') palette = window.ColorPalettes.getColors('categorical', currentRunList.length); } catch(_) {} |
| if (!palette) { |
| const primary = getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim() || '#E889AB'; |
| palette = [primary, '#4EA5B7', '#E38A42', '#CEC0FA', ...(d3.schemeTableau10||[])]; |
| } |
| const colorForRun = (name) => palette[currentRunList.indexOf(name) % palette.length]; |
| |
| |
| const legendItemsHost = legend.querySelector('.items'); |
| function rebuildLegend(){ |
| legendItemsHost.innerHTML = currentRunList.map((name) => { |
| const color = colorForRun(name); |
| return `<span class="item" data-run="${name}"><span class=\"swatch\" style=\"background:${color}\"></span><span>${name}</span></span>`; |
| }).join(''); |
| |
| legendItemsHost.querySelectorAll('.item').forEach(el => { |
| el.addEventListener('mouseenter', () => { |
| const run = el.getAttribute('data-run'); if (!run) return; |
| host.classList.add('hovering'); |
| host.querySelectorAll('.cell').forEach(cell => { |
| cell.querySelectorAll('.lines path.run-line').forEach(p => p.classList.toggle('ghost', p.getAttribute('data-run') !== run)); |
| cell.querySelectorAll('.points circle.pt').forEach(c => c.classList.toggle('ghost', c.getAttribute('data-run') !== run)); |
| cell.querySelectorAll('.areas path.area').forEach(a => a.classList.toggle('ghost', a.getAttribute('data-run') !== run)); |
| }); |
| legendItemsHost.querySelectorAll('.item').forEach(it => it.classList.toggle('ghost', it.getAttribute('data-run') !== run)); |
| }); |
| el.addEventListener('mouseleave', () => { |
| host.classList.remove('hovering'); |
| host.querySelectorAll('.cell').forEach(cell => { |
| cell.querySelectorAll('.lines path.run-line').forEach(p => p.classList.remove('ghost')); |
| cell.querySelectorAll('.points circle.pt').forEach(c => c.classList.remove('ghost')); |
| cell.querySelectorAll('.areas path.area').forEach(a => a.classList.remove('ghost')); |
| }); |
| legendItemsHost.querySelectorAll('.item').forEach(it => it.classList.remove('ghost')); |
| }); |
| }); |
| } |
| rebuildLegend(); |
| |
| |
| const dataByMetric = new Map(); |
| metricsToDraw.forEach(tgt => { |
| const m = TARGET_TO_DATA[tgt]; |
| const map = {}; runList.forEach(r => map[r] = []); |
| rows.filter(r=>r.metric===m).forEach(r => { |
| if (!isNaN(r.step) && !isNaN(r.value)) map[r.run].push({ step:r.step, value:r.value, stderr:r.stderr }); |
| }); |
| dataByMetric.set(tgt, map); |
| }); |
| |
| |
| instances.forEach(inst => { |
| const metricMap = dataByMetric.get(inst.metricKey) || {}; |
| inst.render(metricMap, colorForRun); |
| }); |
| |
| |
| const rerender = () => { instances.forEach(inst => inst.render(dataByMetric.get(inst.metricKey)||{}, colorForRun)); }; |
| |
| let cycleIdx = 2; |
| function jitterData(){ |
| const mList = (metricsToDraw && metricsToDraw.length) ? metricsToDraw : TARGET_METRICS; |
| mList.forEach((tgt) => { |
| if (tgt === 'epoch') return; |
| const map = dataByMetric.get(tgt); if (!map) return; |
| const isAcc = /acc/i.test(tgt); const isLoss = /loss/i.test(tgt); |
| const scale = isAcc ? 0.01 : (isLoss ? 0.03 : 0.02); |
| Object.keys(map).forEach((run) => { |
| map[run].forEach((pt) => { |
| const base = Math.abs(pt.value) || 1; |
| const delta = (Math.random()*2-1) * scale * base; |
| let nv = pt.value + delta; |
| if (isAcc) nv = Math.max(0, Math.min(1, nv)); |
| pt.value = nv; |
| }); |
| }); |
| }); |
| } |
| function genCurves(n){ |
| const quality = Math.random(); const good = quality > 0.66; const poor = quality < 0.33; |
| const l0 = 2.0 + Math.random()*4.5; |
| const targetLoss = good ? l0*(0.12+Math.random()*0.12) : (poor ? l0*(0.35+Math.random()*0.25) : l0*(0.22+Math.random()*0.16)); |
| const phases = 1 + Math.floor(Math.random()*3); |
| const marksSet = new Set(); |
| while (marksSet.size < phases-1) { marksSet.add(Math.floor((0.25+Math.random()*0.5)*(n-1))); } |
| const marks = [0, ...Array.from(marksSet).sort((a,b)=>a-b), n-1]; |
| let kLoss = 0.02 + Math.random()*0.08; const loss = new Array(n); |
| for (let seg=0; seg<marks.length-1; seg++){ |
| const a=marks[seg], b=marks[seg+1]||a+1; |
| for (let i=a;i<=b;i++){ |
| const t = (i-a)/Math.max(1,(b-a)); |
| const segTarget = targetLoss * Math.pow(0.85, seg); |
| let v = l0*Math.exp(-kLoss*(i+1)); |
| v = 0.6*v + 0.4*(l0 + (segTarget - l0)*(seg + t)/Math.max(1,(marks.length-1))); |
| const noiseAmp = (0.08*l0) * (1 - 0.8*(i/(n-1))); |
| v += (Math.random()*2-1)*noiseAmp; if (Math.random() < 0.02) v += 0.15*l0; |
| loss[i] = Math.max(0, v); |
| } |
| kLoss *= 1.6; |
| } |
| const a0 = 0.1 + Math.random()*0.35; const aMax = good ? (0.92+Math.random()*0.07) : (poor ? (0.62+Math.random()*0.14) : (0.8+Math.random()*0.1)); |
| let kAcc = 0.02 + Math.random()*0.08; const acc = new Array(n); |
| for (let i=0;i<n;i++){ |
| let v = aMax - (aMax - a0)*Math.exp(-kAcc*(i+1)); |
| const noiseAmp = 0.04*(1 - 0.8*(i/(n-1))); |
| v += (Math.random()*2-1)*noiseAmp; acc[i] = Math.max(0, Math.min(1, v)); |
| if (marksSet.has(i)) kAcc *= 1.4; |
| } |
| const accGap = 0.02 + Math.random()*0.06; const lossGap = 0.05 + Math.random()*0.15; |
| const accVal = new Array(n); const lossVal = new Array(n); |
| let ofStart = Math.floor(((good ? 0.85 : 0.7) + (Math.random()*0.15 - 0.05))*(n-1)); |
| ofStart = Math.max(Math.floor(0.5*(n-1)), Math.min(Math.floor(0.95*(n-1)), ofStart)); |
| for (let i=0;i<n;i++){ |
| let av = acc[i] - accGap + (Math.random()*0.06 - 0.03); |
| let lv = loss[i]*(1+lossGap) + (Math.random()*0.1 - 0.05)*Math.max(1, l0*0.2); |
| if (i>=ofStart && !poor){ const t = (i-ofStart)/Math.max(1,(n-1-ofStart)); av -= 0.03*t; lv += 0.12*t*loss[i]; } |
| accVal[i] = Math.max(0, Math.min(1, av)); lossVal[i] = Math.max(0, lv); |
| } |
| return { accTrain: acc, lossTrain: loss, accVal, lossVal }; |
| } |
| function generateRunNames(count){ |
| const adjectives = ['ancient','brave','calm','clever','crimson','daring','eager','fearless','gentle','glossy','golden','hidden','icy','jolly','lively','mighty','noble','proud','quick','silent','swift','tiny','vivid','wild']; |
| const nouns = ['river','mountain','harbor','forest','valley','ocean','meadow','desert','island','canyon','harbor','trail','summit','delta','lagoon','ridge','tundra','reef','plateau','prairie','grove','bay','dune','cliff']; |
| const used = new Set(); const names = []; |
| const pick = (arr) => arr[Math.floor(Math.random()*arr.length)]; |
| while (names.length < count) { |
| const name = `${pick(adjectives)}-${pick(nouns)}-${Math.floor(1+Math.random()*9)}`; |
| if (!used.has(name)) { used.add(name); names.push(name); } |
| } |
| return names; |
| } |
| function simulateData(){ |
| |
| const wantRuns = Math.max(2, Math.floor(2 + Math.random()*6)); |
| const runsSim = generateRunNames(wantRuns); |
| const rnd = (min,max)=> Math.floor(min + Math.random()*(max-min+1)); |
| let stepsCount = 16; |
| if (cycleIdx === 0) stepsCount = rnd(4, 12); |
| else if (cycleIdx === 1) stepsCount = rnd(16, 48); |
| else stepsCount = rnd(80, 240); |
| cycleIdx = (cycleIdx + 1) % 3; |
| const steps = Array.from({length: stepsCount}, (_,i)=> i+1); |
| const nextByMetric = new Map(); |
| const mList = (metricsToDraw && metricsToDraw.length) ? metricsToDraw : TARGET_METRICS; |
| mList.forEach(tgt => { const map = {}; runsSim.forEach(r => map[r] = []); nextByMetric.set(tgt, map); }); |
| runsSim.forEach(run => { |
| const curves = genCurves(stepsCount); |
| steps.forEach((s,i)=>{ |
| if (mList.includes('epoch')) nextByMetric.get('epoch')[run].push({ step:s, value:s }); |
| if (mList.includes('train_accuracy')) nextByMetric.get('train_accuracy')[run].push({ step:s, value: curves.accTrain[i] }); |
| if (mList.includes('val_accuracy')) nextByMetric.get('val_accuracy')[run].push({ step:s, value: curves.accVal[i] }); |
| if (mList.includes('train_loss')) nextByMetric.get('train_loss')[run].push({ step:s, value: curves.lossTrain[i] }); |
| if (mList.includes('val_loss')) nextByMetric.get('val_loss')[run].push({ step:s, value: curves.lossVal[i] }); |
| }); |
| });s |
| nextByMetric.forEach((v,k)=> dataByMetric.set(k,v)); |
| |
| currentRunList = runsSim.slice(); |
| try { if (window.ColorPalettes && typeof window.ColorPalettes.getColors==='function') palette = window.ColorPalettes.getColors('categorical', currentRunList.length); } catch(_) {} |
| if (!palette) { const primary = getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim() || '#E889AB'; palette = [primary, '#4EA5B7', '#E38A42', '#CEC0FA', ...(d3.schemeTableau10||[])]; } |
| rebuildLegend(); |
| } |
| const onKeyDownSim = (ev) => { try { const key = ev && ev.key ? ev.key.toLowerCase() : ''; if (key==='r') { jitterData(); rerender(); } if (key==='s') { simulateData(); rerender(); } } catch(_) {} }; |
| window.addEventListener('keydown', onKeyDownSim); |
| |
| simulateData(); |
| rerender(); |
| host.__rerender = rerender; |
| if (window.ResizeObserver) { const ro = new ResizeObserver(() => rerender()); ro.observe(host); } else { window.addEventListener('resize', rerender); } |
| |
| |
| } catch (e) { |
| const pre = document.createElement('pre'); pre.textContent = 'CSV load error: ' + (e && e.message ? e.message : e); |
| pre.style.color = 'var(--danger, #b00020)'; pre.style.fontSize = '12px'; pre.style.whiteSpace = 'pre-wrap'; host.appendChild(pre); |
| } |
| })(); |
| }; |
| |
| if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); } else { ensureD3(bootstrap); } |
| })(); |
| </script> |
|
|
|
|
|
|