lerobot/robot-learning-tutorial
508
1<div class="d3-matrix" ></div>2<style>3 .d3-matrix {4 position: relative;5 }6 .d3-matrix .panels {7 display: flex;8 flex-wrap: wrap;9 gap: 16px;10 margin-bottom: 4px;11 }12 .d3-matrix .panel {13 flex: 1 1 320px;14 min-width: 280px;15 }16 .d3-matrix .panel__title {17 color: var(--text-color);18 font-size: 12px;19 line-height: 1.35;20 margin: 0 0 6px 0;21 font-weight: 600;22 }23 .d3-matrix .axis-label {24 fill: var(--text-color);25 font-size: 11px;26 font-weight: 700;27 }28 .d3-matrix .cell-border {29 stroke: var(--border-color);30 stroke-width: 1px;31 fill: none;32 }33 .d3-matrix .cell-text {34 fill: var(--muted-color);35 font-size: 11px;36 pointer-events: none;37 }38 .d3-matrix .chart-card { background: var(--surface-bg); border: 1px solid var(--border-color); border-radius: 10px; padding: 8px; }39</style>40<script>41 (() => {42 // Load D3 from CDN once43 const ensureD3 = (cb) => {44 if (window.d3 && typeof window.d3.select === 'function') return cb();45 let s = document.getElementById('d3-cdn-script');46 if (!s) {47 s = document.createElement('script');48 s.id = 'd3-cdn-script';49 s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js';50 document.head.appendChild(s);51 }52 const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };53 s.addEventListener('load', onReady, { once: true });54 if (window.d3) onReady();55 };56 57 const bootstrap = () => {58 const scriptEl = document.currentScript;59 let container = scriptEl ? scriptEl.previousElementSibling : null;60 if (!(container && container.classList && container.classList.contains('d3-matrix'))){61 const cs = Array.from(document.querySelectorAll('.d3-matrix')).filter(el => !(el.dataset && el.dataset.mounted === 'true'));62 container = cs[cs.length - 1] || null;63 }64 if (!container) return;65 if (container.dataset) {66 if (container.dataset.mounted === 'true') return;67 container.dataset.mounted = 'true';68 }69 70 // Tooltip (HTML, single instance inside container)71 container.style.position = container.style.position || 'relative';72 let tip = container.querySelector('.d3-tooltip');73 let tipInner;74 if (!tip) {75 tip = document.createElement('div');76 tip.className = 'd3-tooltip';77 Object.assign(tip.style, {78 position: 'absolute',79 top: '0px',80 left: '0px',81 transform: 'translate(-9999px, -9999px)',82 pointerEvents: 'none',83 padding: '8px 10px',84 borderRadius: '8px',85 fontSize: '12px',86 lineHeight: '1.35',87 border: '1px solid var(--border-color)',88 background: 'var(--surface-bg)',89 color: 'var(--text-color)',90 boxShadow: '0 4px 24px rgba(0,0,0,.18)',91 opacity: '0',92 transition: 'opacity .12s ease'93 });94 tipInner = document.createElement('div');95 tipInner.className = 'd3-tooltip__inner';96 tipInner.style.textAlign = 'left';97 tip.appendChild(tipInner);98 container.appendChild(tip);99 } else {100 tipInner = tip.querySelector('.d3-tooltip__inner') || tip;101 }102 103 // Panels container (two side-by-side matrices)104 const panels = document.createElement('div');105 panels.className = 'panels';106 const panelA = document.createElement('div');107 panelA.className = 'panel';108 const titleA = document.createElement('div'); titleA.className = 'panel__title'; titleA.textContent = 'Baseline (row-normalized %)';109 panelA.appendChild(titleA);110 const mountA = document.createElement('div'); panelA.appendChild(mountA);111 const panelB = document.createElement('div');112 panelB.className = 'panel';113 const titleB = document.createElement('div'); titleB.className = 'panel__title'; titleB.textContent = 'Delta (Improved − Baseline, pp)';114 panelB.appendChild(titleB);115 const mountB = document.createElement('div'); panelB.appendChild(mountB);116 panels.appendChild(panelA);117 panels.appendChild(panelB);118 container.appendChild(panels);119 120 // SVG scaffolding121 const cardA = document.createElement('div'); cardA.className = 'chart-card'; mountA.appendChild(cardA);122 const svgA = d3.select(cardA).append('svg').attr('width', '100%').style('display', 'block');123 const gRootA = svgA.append('g');124 const gCellsA = gRootA.append('g');125 const gAxesA = gRootA.append('g');126 const cardB = document.createElement('div'); cardB.className = 'chart-card'; mountB.appendChild(cardB);127 const svgB = d3.select(cardB).append('svg').attr('width', '100%').style('display', 'block');128 const gRootB = svgB.append('g');129 const gCellsB = gRootB.append('g');130 const gAxesB = gRootB.append('g');131 132 // Demo data (two distinct 10x10 matrices: Baseline vs Improved)133 // Rows / Columns are generic class labels134 const classes = ['0','1','2','3','4','5','6','7','8','9'];135 const matrixA = [136 [90, 2, 1, 0, 0, 0, 1, 0, 5, 1],137 [ 3, 85, 5, 1, 0, 1, 2, 1, 1, 1],138 [ 1, 6, 70, 10, 4, 4, 1, 1, 1, 2],139 [ 0, 1, 8, 65, 10, 10, 2, 1, 1, 2],140 [ 0, 0, 2, 6, 83, 3, 1, 1, 3, 1],141 [ 0, 1, 2, 12, 4, 70, 5, 2, 2, 2],142 [ 1, 2, 1, 0, 1, 2, 88, 1, 3, 1],143 [ 0, 1, 1, 1, 1, 1, 2, 90, 1, 2],144 [ 6, 2, 2, 4, 6, 3, 3, 2, 70, 2],145 [ 1, 1, 1, 1, 2, 1, 1, 2, 1, 89]146 ];147 const matrixB = [148 [94, 1, 0, 0, 0, 0, 1, 0, 3, 1],149 [ 2, 90, 3, 1, 0, 0, 1, 1, 1, 1],150 [ 1, 4, 78, 7, 3, 3, 1, 1, 1, 1],151 [ 0, 1, 5, 74, 7, 8, 1, 1, 1, 2],152 [ 0, 0, 1, 4, 88, 2, 1, 1, 2, 1],153 [ 0, 1, 1, 9, 3, 78, 3, 1, 2, 2],154 [ 1, 1, 1, 0, 1, 1, 91, 1, 2, 1],155 [ 0, 1, 1, 1, 1, 1, 1, 92, 1, 1],156 [ 4, 1, 1, 3, 4, 2, 2, 2, 79, 2],157 [ 1, 1, 1, 1, 2, 1, 1, 1, 1, 90]158 ];159 160 // Colors: sequential palette via window.ColorPalettes with graceful fallback161 const getSequentialColors = (count) => {162 try {163 if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {164 return window.ColorPalettes.getColors('sequential', count);165 }166 } catch (_) {}167 // Fallback: generate a monochrome scale using the primary color with varying opacity168 const arr = [];169 for (let i = 0; i < count; i++) arr.push('var(--primary-color)');170 return arr;171 };172 173 const palette = getSequentialColors(13);174 const getDivergingColors = (count) => {175 try {176 if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {177 return window.ColorPalettes.getColors('diverging', count);178 }179 } catch (_) {}180 const steps = Math.max(3, count|0);181 const arr = [];182 for (let i = 0; i < steps; i++) {183 const t = i / (steps - 1);184 const pct = Math.round(t * 100);185 arr.push(`color-mix(in srgb, #D64545 ${100-pct}%, #3A7BD5 ${pct}%)`);186 }187 return arr;188 };189 190 let width = 800;191 let height = 480;192 const margin = { top: 36, right: 24, bottom: 26, left: 56 };193 194 function updateSize() {195 const isDark = document.documentElement.getAttribute('data-theme') === 'dark';196 width = container.clientWidth || 800;197 const gap = 16; // matches CSS .panels gap198 const minPanel = 320;199 const nCols = (width >= (minPanel * 2 + gap)) ? 2 : 1;200 const panelWidth = nCols === 2 ? Math.max(minPanel, Math.floor((width - gap) / 2)) : Math.max(minPanel, width);201 const base = Math.max(minPanel, Math.round(panelWidth * 0.92));202 height = base;203 // Responsive SVG: width 100%, height auto, preserve aspect via viewBox204 svgA205 .attr('viewBox', `0 0 ${panelWidth} ${height}`)206 .attr('preserveAspectRatio', 'xMidYMid meet')207 .style('width', '100%')208 .style('height', 'auto');209 svgB210 .attr('viewBox', `0 0 ${panelWidth} ${height}`)211 .attr('preserveAspectRatio', 'xMidYMid meet')212 .style('width', '100%')213 .style('height', 'auto');214 gRootA.attr('transform', `translate(${margin.left},${margin.top})`);215 gRootB.attr('transform', `translate(${margin.left},${margin.top})`);216 const innerWidth = panelWidth - margin.left - margin.right;217 const innerHeight = height - margin.top - margin.bottom;218 return { innerWidth, innerHeight, isDark };219 }220 221 function computeValues(normalization, matrix) {222 const n = classes.length;223 const totalsByRow = matrix.map(row => row.reduce((a, b) => a + b, 0));224 const flat = [];225 let minV = Infinity, maxV = -Infinity;226 for (let r = 0; r < n; r++) {227 for (let c = 0; c < n; c++) {228 const count = matrix[r][c];229 const value = normalization === 'row' ? (totalsByRow[r] ? count / totalsByRow[r] : 0) : count;230 if (value < minV) minV = value;231 if (value > maxV) maxV = value;232 flat.push({ r, c, count, value });233 }234 }235 return { data: flat, minV, maxV };236 }237 238 function getColorScale(values, minV, maxV) {239 // If ColorPalettes is available, use quantiles to enhance visual variation across the distribution240 const hasPalette = !(palette.length === 0);241 if (hasPalette && (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function')) {242 const scale = d3.scaleQuantile().domain(values).range(palette);243 return (v) => scale(v);244 }245 // Fallback: primary color with opacity mapped to normalized value246 const norm = d3.scaleLinear().domain([minV, maxV]).range([0.08, 0.9]).clamp(true);247 return (v) => `color-mix(in oklab, var(--primary-color) ${Math.round(norm(v) * 100)}%, var(--surface-bg))`;248 }249 250 // Compute a fixed readable text color from a CSS rgb()/rgba() string251 function chooseFixedReadableTextOnBg(bgCss){252 try {253 const m = String(bgCss||'').match(/rgba?\(([^)]+)\)/);254 if (!m) return '#0e1116';255 const parts = m[1].split(',').map(s => parseFloat(s.trim()));256 const [r, g, b] = parts;257 // sRGB → relative luminance258 const srgb = [r, g, b].map(v => Math.max(0, Math.min(255, v)) / 255);259 const linear = srgb.map(c => (c <= 0.03928 ? c/12.92 : Math.pow((c + 0.055)/1.055, 2.4)));260 const L = 0.2126*linear[0] + 0.7152*linear[1] + 0.0722*linear[2];261 // Threshold ~ 0.5 for readability; darker BG → white text, else near-black262 return L < 0.5 ? '#ffffff' : '#0e1116';263 } catch(_) { return '#0e1116'; }264 }265 266 function render() {267 const { innerWidth, innerHeight } = updateSize();268 const n = classes.length;269 const gridSize = Math.min(innerWidth, innerHeight);270 const cellSize = gridSize / n;271 272 const x = d3.scaleBand().domain(d3.range(n)).range([0, gridSize]).paddingInner(0.06);273 const y = d3.scaleBand().domain(d3.range(n)).range([0, gridSize]).paddingInner(0.06);274 275 // Panel A: Baseline (row-normalized)276 const dataA = computeValues('row', matrixA);277 const colorA = getColorScale(dataA.data.map(d => d.value), dataA.minV, dataA.maxV);278 279 gCellsA.selectAll('rect.cell-bg')280 .data([0])281 .join('rect')282 .attr('class', 'cell-bg')283 .attr('x', 0)284 .attr('y', 0)285 .attr('width', gridSize)286 .attr('height', gridSize)287 .attr('fill', 'none')288 .attr('stroke', 'var(--border-color)')289 .attr('stroke-width', 1);290 291 const cellsA = gCellsA.selectAll('g.cell')292 .data(dataA.data, d => `${d.r}-${d.c}-A`);293 294 const cellsEnterA = cellsA.enter()295 .append('g')296 .attr('class', 'cell');297 298 cellsEnterA.append('rect')299 .attr('rx', 2)300 .attr('ry', 2)301 .on('mousemove', (event, d) => {302 const [px, py] = d3.pointer(event, container);303 tipInner.innerHTML = `<strong>${classes[d.r]}</strong> → <strong>${classes[d.c]}</strong><br/>${(d.value * 100).toFixed(1)}% (${d.count})`;304 tip.style.transform = `translate(${px + 10}px, ${py + 10}px)`;305 tip.style.opacity = '1';306 })307 .on('mouseleave', () => {308 tip.style.opacity = '0';309 });310 311 cellsEnterA.append('text')312 .attr('class', 'cell-text')313 .attr('text-anchor', 'middle')314 .attr('dominant-baseline', 'middle');315 316 const cellsMergedA = cellsEnterA.merge(cellsA);317 318 cellsMergedA.select('text')319 .attr('x', d => x(d.c) + x.bandwidth() / 2)320 .attr('y', d => y(d.r) + y.bandwidth() / 2)321 .text(d => `${Math.round(d.value * 100)}`)322 .style('fill', function(d){323 try {324 const rect = this && this.parentNode ? this.parentNode.querySelector('rect') : null;325 const bg = rect ? getComputedStyle(rect).fill : colorA(d.value);326 return chooseFixedReadableTextOnBg(bg);327 } catch (_) {328 return '#0e1116';329 }330 });331 332 cellsMergedA.select('rect')333 .attr('x', d => x(d.c))334 .attr('y', d => y(d.r))335 .attr('width', Math.max(1, x.bandwidth()))336 .attr('height', Math.max(1, y.bandwidth()))337 .attr('fill', d => colorA(d.value));338 339 cellsA.exit().remove();340 341 gAxesA.selectAll('*').remove();342 343 gAxesA.append('g')344 .selectAll('text')345 .data(classes)346 .join('text')347 .attr('class', 'axis-label')348 .attr('text-anchor', 'middle')349 .attr('x', (_, i) => x(i) + x.bandwidth() / 2)350 .attr('y', -8)351 .text(d => d);352 353 gAxesA.append('g')354 .selectAll('text')355 .data(classes)356 .join('text')357 .attr('class', 'axis-label')358 .attr('text-anchor', 'end')359 .attr('x', -8)360 .attr('y', (_, i) => y(i) + y.bandwidth() / 2)361 .attr('dominant-baseline', 'middle')362 .text(d => d);363 364 gAxesA.append('text')365 .attr('class', 'axis-label')366 .attr('text-anchor', 'middle')367 .attr('x', gridSize / 2)368 .attr('y', innerHeight + 20)369 .text('Columns');370 371 gAxesA.append('text')372 .attr('class', 'axis-label')373 .attr('text-anchor', 'middle')374 .attr('transform', `translate(${-40}, ${gridSize / 2}) rotate(-90)`)375 .text('Rows');376 377 // Panel B: Delta (Improved − Baseline), row-normalized differences in percentage points378 const dataB = computeValues('row', matrixB);379 const diverging = getDivergingColors(13);380 // Build delta values aligned to A's ordering381 const mapA = new Map(dataA.data.map(d => [d.r + '-' + d.c, d.value]));382 const delta = dataB.data.map(d => ({ r: d.r, c: d.c, count: d.count, value: (d.value - (mapA.get(d.r + '-' + d.c) || 0)) }));383 // Symmetric domain around 0 (in proportions), express later as pp in labels384 const maxAbsDelta = Math.max(0.01, d3.max(delta, d => Math.abs(d.value)) || 0.01);385 const colorB = d3.scaleQuantize().domain([-maxAbsDelta, maxAbsDelta]).range(diverging);386 387 gCellsB.selectAll('rect.cell-bg')388 .data([0])389 .join('rect')390 .attr('class', 'cell-bg')391 .attr('x', 0)392 .attr('y', 0)393 .attr('width', gridSize)394 .attr('height', gridSize)395 .attr('fill', 'none')396 .attr('stroke', 'var(--border-color)')397 .attr('stroke-width', 1);398 399 const cellsB = gCellsB.selectAll('g.cell')400 .data(dataB.data, d => `${d.r}-${d.c}-B`);401 402 const cellsEnterB = cellsB.enter()403 .append('g')404 .attr('class', 'cell');405 406 cellsEnterB.append('rect')407 .attr('rx', 2)408 .attr('ry', 2)409 .on('mousemove', (event, d) => {410 const [px, py] = d3.pointer(event, container);411 const a = dataA.data.find(x => x.r===d.r && x.c===d.c);412 const b = dataB.data.find(x => x.r===d.r && x.c===d.c);413 const dv = ((b ? b.value : 0) - (a ? a.value : 0)) * 100;414 tipInner.innerHTML = `<strong>${classes[d.r]}</strong> → <strong>${classes[d.c]}</strong>` +415 `<br/>baseline ${(a ? a.value*100 : 0).toFixed(1)}%` +416 `<br/>improved ${(b ? b.value*100 : 0).toFixed(1)}%` +417 `<br/>delta ${dv.toFixed(1)} pp`;418 tip.style.transform = `translate(${px + 10}px, ${py + 10}px)`;419 tip.style.opacity = '1';420 })421 .on('mouseleave', () => {422 tip.style.opacity = '0';423 });424 425 cellsEnterB.append('text')426 .attr('class', 'cell-text')427 .attr('text-anchor', 'middle')428 .attr('dominant-baseline', 'middle');429 430 const cellsMergedB = cellsEnterB.merge(cellsB);431 432 cellsMergedB.select('rect')433 .attr('x', d => x(d.c))434 .attr('y', d => y(d.r))435 .attr('width', Math.max(1, x.bandwidth()))436 .attr('height', Math.max(1, y.bandwidth()))437 .attr('fill', d => colorB(delta.find(x => x.r===d.r && x.c===d.c).value));438 439 cellsMergedB.select('text')440 .attr('x', d => x(d.c) + x.bandwidth() / 2)441 .attr('y', d => y(d.r) + y.bandwidth() / 2)442 .text(d => {443 const dv = delta.find(x => x.r===d.r && x.c===d.c).value; return `${Math.round(dv * 100)}`;444 })445 .style('fill', function(d){446 try {447 const rect = this && this.parentNode ? this.parentNode.querySelector('rect') : null;448 const dv = delta.find(x => x.r===d.r && x.c===d.c).value;449 const bg = rect ? getComputedStyle(rect).fill : colorB(dv);450 return chooseFixedReadableTextOnBg(bg);451 } catch (_) {452 return '#0e1116';453 }454 });455 456 cellsB.exit().remove();457 458 gAxesB.selectAll('*').remove();459 460 gAxesB.append('g')461 .selectAll('text')462 .data(classes)463 .join('text')464 .attr('class', 'axis-label')465 .attr('text-anchor', 'middle')466 .attr('x', (_, i) => x(i) + x.bandwidth() / 2)467 .attr('y', -8)468 .text(d => d);469 470 gAxesB.append('g')471 .selectAll('text')472 .data(classes)473 .join('text')474 .attr('class', 'axis-label')475 .attr('text-anchor', 'end')476 .attr('x', -8)477 .attr('y', (_, i) => y(i) + y.bandwidth() / 2)478 .attr('dominant-baseline', 'middle')479 .text(d => d);480 481 gAxesB.append('text')482 .attr('class', 'axis-label')483 .attr('text-anchor', 'middle')484 .attr('x', gridSize / 2)485 .attr('y', innerHeight + 20)486 .text('Columns');487 488 gAxesB.append('text')489 .attr('class', 'axis-label')490 .attr('text-anchor', 'middle')491 .attr('transform', `translate(${-40}, ${gridSize / 2}) rotate(-90)`)492 .text('Rows');493 }494 495 // Initial render + resize handling496 render();497 const rerender = () => render();498 if (window.ResizeObserver) {499 const ro = new ResizeObserver(() => rerender());500 ro.observe(container);501 } else {502 window.addEventListener('resize', rerender);503 }504 };505 506 if (document.readyState === 'loading') {507 document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });508 } else {509 ensureD3(bootstrap);510 }511 })();512</script>513 514 515 516 