| |
| |
| |
| |
| |
| export const SEED_CONFUSION_MATRIX_HTML = `<div class="d3-confusion-matrix"></div> |
| <style> |
| .d3-confusion-matrix { position: relative; } |
| .d3-confusion-matrix .panels { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 4px; } |
| .d3-confusion-matrix .panel { flex: 1 1 320px; min-width: 280px; } |
| .d3-confusion-matrix .panel__title { color: var(--text-color); font-size: 12px; line-height: 1.35; margin: 0 0 6px 0; font-weight: 600; } |
| .d3-confusion-matrix .axis-label { fill: var(--text-color); font-size: 11px; font-weight: 700; } |
| .d3-confusion-matrix .cell-text { fill: var(--muted-color); font-size: 11px; pointer-events: none; } |
| .d3-confusion-matrix .chart-card { background: var(--surface-bg); border: 1px solid var(--border-color); border-radius: 10px; padding: 8px; } |
| </style> |
| <script> |
| (() => { |
| 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(); |
| }; |
| |
| const bootstrap = () => { |
| const scriptEl = document.currentScript; |
| let container = scriptEl ? scriptEl.previousElementSibling : null; |
| if (!(container && container.classList && container.classList.contains('d3-confusion-matrix'))) { |
| const cs = Array.from(document.querySelectorAll('.d3-confusion-matrix')).filter(el => !(el.dataset && el.dataset.mounted === 'true')); |
| container = cs[cs.length - 1] || null; |
| } |
| if (!container) return; |
| if (container.dataset) { if (container.dataset.mounted === 'true') return; container.dataset.mounted = 'true'; } |
| |
| container.style.position = container.style.position || 'relative'; |
| let tip = container.querySelector('.d3-tooltip'); let tipInner; |
| if (!tip) { |
| tip = document.createElement('div'); tip.className = 'd3-tooltip'; |
| Object.assign(tip.style, { position: 'absolute', top: '0px', left: '0px', transform: 'translate(-9999px, -9999px)', pointerEvents: 'none', padding: '8px 10px', borderRadius: '8px', fontSize: '12px', lineHeight: '1.35', border: '1px solid var(--border-color)', background: 'var(--surface-bg)', color: 'var(--text-color)', boxShadow: '0 4px 24px rgba(0,0,0,.18)', opacity: '0', transition: 'opacity .12s ease' }); |
| tipInner = document.createElement('div'); tipInner.className = 'd3-tooltip__inner'; tipInner.style.textAlign = 'left'; tip.appendChild(tipInner); container.appendChild(tip); |
| } else { tipInner = tip.querySelector('.d3-tooltip__inner') || tip; } |
| |
| const panels = document.createElement('div'); panels.className = 'panels'; |
| const panelA = document.createElement('div'); panelA.className = 'panel'; |
| const titleA = document.createElement('div'); titleA.className = 'panel__title'; titleA.textContent = 'Baseline (row-normalized %)'; |
| panelA.appendChild(titleA); |
| const mountA = document.createElement('div'); panelA.appendChild(mountA); |
| const panelB = document.createElement('div'); panelB.className = 'panel'; |
| const titleB = document.createElement('div'); titleB.className = 'panel__title'; titleB.textContent = 'Delta (Improved - Baseline, pp)'; |
| panelB.appendChild(titleB); |
| const mountB = document.createElement('div'); panelB.appendChild(mountB); |
| panels.appendChild(panelA); panels.appendChild(panelB); |
| container.appendChild(panels); |
| |
| const cardA = document.createElement('div'); cardA.className = 'chart-card'; mountA.appendChild(cardA); |
| const svgA = d3.select(cardA).append('svg').attr('width', '100%').style('display', 'block'); |
| const gRootA = svgA.append('g'); const gCellsA = gRootA.append('g'); const gAxesA = gRootA.append('g'); |
| const cardB = document.createElement('div'); cardB.className = 'chart-card'; mountB.appendChild(cardB); |
| const svgB = d3.select(cardB).append('svg').attr('width', '100%').style('display', 'block'); |
| const gRootB = svgB.append('g'); const gCellsB = gRootB.append('g'); const gAxesB = gRootB.append('g'); |
| |
| const classes = ['0','1','2','3','4','5','6','7','8','9']; |
| const matrixA = [[90,2,1,0,0,0,1,0,5,1],[3,85,5,1,0,1,2,1,1,1],[1,6,70,10,4,4,1,1,1,2],[0,1,8,65,10,10,2,1,1,2],[0,0,2,6,83,3,1,1,3,1],[0,1,2,12,4,70,5,2,2,2],[1,2,1,0,1,2,88,1,3,1],[0,1,1,1,1,1,2,90,1,2],[6,2,2,4,6,3,3,2,70,2],[1,1,1,1,2,1,1,2,1,89]]; |
| const matrixB = [[94,1,0,0,0,0,1,0,3,1],[2,90,3,1,0,0,1,1,1,1],[1,4,78,7,3,3,1,1,1,1],[0,1,5,74,7,8,1,1,1,2],[0,0,1,4,88,2,1,1,2,1],[0,1,1,9,3,78,3,1,2,2],[1,1,1,0,1,1,91,1,2,1],[0,1,1,1,1,1,1,92,1,1],[4,1,1,3,4,2,2,2,79,2],[1,1,1,1,2,1,1,1,1,90]]; |
| |
| const getSequentialColors = (count) => { |
| try { if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') { return window.ColorPalettes.getColors('sequential', count); } } catch (_) {} |
| const arr = []; for (let i = 0; i < count; i++) arr.push('var(--primary-color)'); return arr; |
| }; |
| const palette = getSequentialColors(13); |
| const getDivergingColors = (count) => { |
| try { if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') { return window.ColorPalettes.getColors('diverging', count); } } catch (_) {} |
| const arr = []; for (let i = 0; i < count; i++) { const t = i / (count - 1); arr.push('color-mix(in srgb, #D64545 ' + Math.round((1-t)*100) + '%, #3A7BD5 ' + Math.round(t*100) + '%)'); } return arr; |
| }; |
| |
| const margin = { top: 36, right: 24, bottom: 26, left: 56 }; |
| let width = 800, height = 480; |
| |
| function chooseTextOnBg(bgCss) { |
| try { |
| var m = String(bgCss||'').match(/rgba?\\(([^)]+)\\)/); |
| if (!m) return '#0e1116'; |
| var parts = m[1].split(',').map(function(s){ return parseFloat(s.trim()); }); |
| var srgb = [parts[0],parts[1],parts[2]].map(function(v){ return Math.max(0,Math.min(255,v))/255; }); |
| var linear = srgb.map(function(c){ return c<=0.03928?c/12.92:Math.pow((c+0.055)/1.055,2.4); }); |
| var L = 0.2126*linear[0]+0.7152*linear[1]+0.0722*linear[2]; |
| return L < 0.5 ? '#ffffff' : '#0e1116'; |
| } catch(_){ return '#0e1116'; } |
| } |
| |
| function updateSize() { |
| width = container.clientWidth || 800; |
| var gap = 16; var minPanel = 320; |
| var nCols = (width >= (minPanel * 2 + gap)) ? 2 : 1; |
| var panelWidth = nCols === 2 ? Math.max(minPanel, Math.floor((width - gap) / 2)) : Math.max(minPanel, width); |
| var base = Math.max(minPanel, Math.round(panelWidth * 0.92)); |
| height = base; |
| svgA.attr('viewBox', '0 0 ' + panelWidth + ' ' + height).attr('preserveAspectRatio', 'xMidYMid meet').style('width', '100%').style('height', 'auto'); |
| svgB.attr('viewBox', '0 0 ' + panelWidth + ' ' + height).attr('preserveAspectRatio', 'xMidYMid meet').style('width', '100%').style('height', 'auto'); |
| gRootA.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); |
| gRootB.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); |
| var innerWidth = panelWidth - margin.left - margin.right; |
| var innerHeight = height - margin.top - margin.bottom; |
| return { innerWidth: innerWidth, innerHeight: innerHeight }; |
| } |
| |
| function computeValues(matrix) { |
| var n = classes.length; |
| var totalsByRow = matrix.map(function(row){ return row.reduce(function(a,b){return a+b;},0); }); |
| var flat = []; var minV = Infinity; var maxV = -Infinity; |
| for (var r = 0; r < n; r++) { for (var c = 0; c < n; c++) { var count = matrix[r][c]; var value = totalsByRow[r] ? count / totalsByRow[r] : 0; if (value < minV) minV = value; if (value > maxV) maxV = value; flat.push({ r: r, c: c, count: count, value: value }); } } |
| return { data: flat, minV: minV, maxV: maxV }; |
| } |
| |
| function getColorScale(values, minV, maxV) { |
| var hasPalette = palette.length > 0; |
| if (hasPalette && window.ColorPalettes) { var scale = d3.scaleQuantile().domain(values).range(palette); return function(v){ return scale(v); }; } |
| var norm = d3.scaleLinear().domain([minV, maxV]).range([0.08, 0.9]).clamp(true); |
| return function(v){ return 'color-mix(in oklab, var(--primary-color) ' + Math.round(norm(v)*100) + '%, var(--surface-bg))'; }; |
| } |
| |
| function render() { |
| var sizes = updateSize(); |
| var n = classes.length; |
| var gridSize = Math.min(sizes.innerWidth, sizes.innerHeight); |
| var cellSize = gridSize / n; |
| var x = d3.scaleBand().domain(d3.range(n)).range([0, gridSize]).paddingInner(0.06); |
| var yScale = d3.scaleBand().domain(d3.range(n)).range([0, gridSize]).paddingInner(0.06); |
| |
| var dataA = computeValues(matrixA); |
| var colorA = getColorScale(dataA.data.map(function(d){return d.value;}), dataA.minV, dataA.maxV); |
| |
| gCellsA.selectAll('*').remove(); |
| dataA.data.forEach(function(d) { |
| gCellsA.append('rect').attr('x', x(d.c)).attr('y', yScale(d.r)).attr('width', x.bandwidth()).attr('height', yScale.bandwidth()).attr('rx', 2).attr('fill', colorA(d.value)) |
| .on('mousemove', function(event) { var p = d3.pointer(event, container); tipInner.innerHTML = '<strong>' + classes[d.r] + '</strong> → <strong>' + classes[d.c] + '</strong><br/>' + (d.value*100).toFixed(1) + '% (' + d.count + ')'; tip.style.transform = 'translate(' + (p[0]+10) + 'px, ' + (p[1]+10) + 'px)'; tip.style.opacity = '1'; }) |
| .on('mouseleave', function() { tip.style.opacity = '0'; }); |
| gCellsA.append('text').attr('class', 'cell-text').attr('text-anchor', 'middle').attr('dominant-baseline', 'middle').attr('x', x(d.c) + x.bandwidth()/2).attr('y', yScale(d.r) + yScale.bandwidth()/2).text(Math.round(d.value*100)); |
| }); |
| |
| gAxesA.selectAll('*').remove(); |
| classes.forEach(function(c, i) { |
| gAxesA.append('text').attr('class', 'axis-label').attr('text-anchor', 'middle').attr('x', x(i) + x.bandwidth()/2).attr('y', -8).text(c); |
| gAxesA.append('text').attr('class', 'axis-label').attr('text-anchor', 'end').attr('x', -8).attr('y', yScale(i) + yScale.bandwidth()/2).attr('dominant-baseline', 'middle').text(c); |
| }); |
| gAxesA.append('text').attr('class', 'axis-label').attr('text-anchor', 'middle').attr('x', gridSize/2).attr('y', sizes.innerHeight + 20).text('Predicted'); |
| gAxesA.append('text').attr('class', 'axis-label').attr('text-anchor', 'middle').attr('transform', 'translate(' + (-40) + ',' + (gridSize/2) + ') rotate(-90)').text('Actual'); |
| |
| var dataB = computeValues(matrixB); |
| var diverging = getDivergingColors(13); |
| var mapA = new Map(dataA.data.map(function(d){ return [d.r + '-' + d.c, d.value]; })); |
| var delta = dataB.data.map(function(d){ return { r: d.r, c: d.c, count: d.count, value: d.value - (mapA.get(d.r + '-' + d.c) || 0) }; }); |
| var maxAbsDelta = Math.max(0.01, d3.max(delta, function(d){ return Math.abs(d.value); }) || 0.01); |
| var colorB = d3.scaleQuantize().domain([-maxAbsDelta, maxAbsDelta]).range(diverging); |
| |
| gCellsB.selectAll('*').remove(); |
| delta.forEach(function(d, i) { |
| gCellsB.append('rect').attr('x', x(d.c)).attr('y', yScale(d.r)).attr('width', x.bandwidth()).attr('height', yScale.bandwidth()).attr('rx', 2).attr('fill', colorB(d.value)) |
| .on('mousemove', function(event) { var p = d3.pointer(event, container); var a = dataA.data.find(function(x){ return x.r===d.r && x.c===d.c; }); var b = dataB.data.find(function(x){ return x.r===d.r && x.c===d.c; }); var dv = ((b?b.value:0)-(a?a.value:0))*100; tipInner.innerHTML = '<strong>' + classes[d.r] + '</strong> → <strong>' + classes[d.c] + '</strong><br/>baseline ' + (a?a.value*100:0).toFixed(1) + '%<br/>improved ' + (b?b.value*100:0).toFixed(1) + '%<br/>delta ' + dv.toFixed(1) + ' pp'; tip.style.transform = 'translate(' + (p[0]+10) + 'px, ' + (p[1]+10) + 'px)'; tip.style.opacity = '1'; }) |
| .on('mouseleave', function() { tip.style.opacity = '0'; }); |
| gCellsB.append('text').attr('class', 'cell-text').attr('text-anchor', 'middle').attr('dominant-baseline', 'middle').attr('x', x(d.c) + x.bandwidth()/2).attr('y', yScale(d.r) + yScale.bandwidth()/2).text(Math.round(d.value*100)); |
| }); |
| |
| gAxesB.selectAll('*').remove(); |
| classes.forEach(function(c, i) { |
| gAxesB.append('text').attr('class', 'axis-label').attr('text-anchor', 'middle').attr('x', x(i) + x.bandwidth()/2).attr('y', -8).text(c); |
| gAxesB.append('text').attr('class', 'axis-label').attr('text-anchor', 'end').attr('x', -8).attr('y', yScale(i) + yScale.bandwidth()/2).attr('dominant-baseline', 'middle').text(c); |
| }); |
| gAxesB.append('text').attr('class', 'axis-label').attr('text-anchor', 'middle').attr('x', gridSize/2).attr('y', sizes.innerHeight + 20).text('Predicted'); |
| gAxesB.append('text').attr('class', 'axis-label').attr('text-anchor', 'middle').attr('transform', 'translate(' + (-40) + ',' + (gridSize/2) + ') rotate(-90)').text('Actual'); |
| } |
| |
| render(); |
| if (window.ResizeObserver) { new ResizeObserver(function(){ render(); }).observe(container); } |
| }; |
| |
| if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', function(){ ensureD3(bootstrap); }, { once: true }); } else { ensureD3(bootstrap); } |
| })(); |
| <\/script>`; |
|
|