thibaud frere
update trackio design
9a962ce
raw
history blame
47.6 kB
<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;
/* Softer chart theming (light) */
--axis-color: rgba(0,0,0,.18);
--tick-color: rgba(0,0,0,.50);
--grid-color: rgba(0,0,0,.05);
}
/* Softer chart theming (dark) */
[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; }
/* Fullscreen overlay */
.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); }
/* Conserver le ratio pour éviter les décalages points/ticks lors du zoom */
.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; }
/* Axes/grid colors via project CSS variables */
.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); }
/* Global header (legend) above the grid and centered */
.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; }
/* Hover ghosting */
.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; }
/* Tooltip styling aligned with other embeds */
.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();
};
// Build per-cell line chart
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');
// Header
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);
// Body & SVG
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');
// Legacy flag, kept for compatibility but computed via shouldSuppress()
let suppressTransitions = false;
// Tooltip
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; }
// Layout & scales
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)));
// Hauteur fixe par défaut, overridable via data-height (pour fullscreen)
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]);
// Grid
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');
// Axes
gAxes.selectAll('*').remove();
// Ticks (Oblivion logic):
// - X: nice values mapped to nearest indices, include edges, density by width
// - Y: at least 5 ticks, density by height
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))));
// X axis with forced ticks (indices) and formatted labels mapped to original steps in render()
gAxes.append('g')
.attr('transform', `translate(0,${innerHeight})`)
.call(
d3.axisBottom(xScale)
.tickValues(xTicksForced)
.tickFormat((i) => {
// Label mapping will be provided by caller via closure over xTicksArg
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'); });
// Y axis with forced ticks
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'); });
// Axis labels (only X; Y-label removed to gain space)
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 };
}
// Generic number abbreviation for ticks (K/M/B) with up to 2 decimals
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) {
// Show a single empty message and hide the SVG group
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;
}
// Ensure message is removed if previously shown
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();
// Build unique steps and index mapping for equal spacing and snapping
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)]);
// Update line generator X accessor to use index directly
lineGen.x(d => xScale(stepIndex.get(d.step)));
const { innerWidth, innerHeight, xTicksForced } = updateLayout(axisLabelY, hoverSteps);
// Vertical grid lines at each step index (same visibility as horizontal)
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()
);
// Remove stderr correction shapes (no uncertainty area)
gAreas.selectAll('*').remove();
// Lines
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();
// Points
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){ /* placeholder to keep merge chain intact */ })
.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();
// Hover
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');
// Expose external hover handlers for cross-chart sync
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)`;
// No animation on chart while scaling up/down
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);
}
// Fullscreen: button + modal behavior
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='&#10005;';
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; }
// Global suppression across charts inside this host
host.dataset.suppressTransitions = '1';
// Cancel any in-flight D3 transitions
try { d3.select(host).selectAll('path.run-line').interrupt(); d3.select(host).selectAll('circle.pt').interrupt(); } catch(_){ }
// FLIP: animate from current (modal) to placeholder position
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');
// Recompute layout without any line/point animation
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');
// If another chart is open, close it first then proceed
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;
}
// Global suppression across charts inside this host
hostNode.dataset.suppressTransitions = '1';
// Cancel any in-flight D3 transitions
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);
// Do NOT re-render or change SVG internals; scale via CSS only
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(()=>{ /* keep suppress until settled or close */ }); };
cell.addEventListener('transitionend', onEnd);
});
return { metricKey, render };
}
const bootstrap = () => {
const scriptEl = THIS_SCRIPT;
// Locate host
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';
// Build global header (legend) and insert ABOVE the grid
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));
// Read HtmlEmbed attributes from closest ancestor carrying them
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 {
// Load one or many CSVs and merge rows
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
}));
// Filter to target metrics if present in data, with synonym resolution
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) => {
// Config override
const override = providedConfig && providedConfig.metricMap && providedConfig.metricMap[target];
if (override && lcSet.has(String(override).toLowerCase())) return metricsInData.find(m => m.toLowerCase() === String(override).toLowerCase());
// Exact
const exact = metricsInData.find(m => m.toLowerCase() === target.toLowerCase()); if (exact) return exact;
// Heuristics by target
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]);
// Build run list and shared color mapping
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];
// Populate legend
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('');
// Bind hover ghosting
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();
// Build per-metric data map (using resolved names)
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);
});
// Render each cell
instances.forEach(inst => {
const metricMap = dataByMetric.get(inst.metricKey) || {};
inst.render(metricMap, colorForRun);
});
// Resize handling
const rerender = () => { instances.forEach(inst => inst.render(dataByMetric.get(inst.metricKey)||{}, colorForRun)); };
// Synthetic data support: jitter (r) and simulation (s)
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(){
// Randomize number of runs between 2 and 7 with W&B-like names
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));
// Update currentRunList and palette, then legend
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);
// Start with level 3 (long) synthetic data by default
simulateData();
rerender();
host.__rerender = rerender;
if (window.ResizeObserver) { const ro = new ResizeObserver(() => rerender()); ro.observe(host); } else { window.addEventListener('resize', rerender); }
// (Legend hover handlers bound inside rebuildLegend())
} 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>