Spaces:
Running
Running
| <div class="d3-matrix"></div> | |
| <style> | |
| .d3-matrix { | |
| position: relative; | |
| } | |
| .d3-matrix .panels { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 16px; | |
| margin-bottom: 4px; | |
| } | |
| .d3-matrix .panel { | |
| flex: 1 1 320px; | |
| min-width: 280px; | |
| } | |
| .d3-matrix .panel__title { | |
| color: var(--text-color); | |
| font-size: 12px; | |
| line-height: 1.35; | |
| margin: 0 0 6px 0; | |
| font-weight: 600; | |
| } | |
| .d3-matrix .axis-label { | |
| fill: var(--text-color); | |
| font-size: 11px; | |
| font-weight: 700; | |
| } | |
| .d3-matrix .cell-border { | |
| stroke: var(--border-color); | |
| stroke-width: 1px; | |
| fill: none; | |
| } | |
| .d3-matrix .cell-text { | |
| fill: var(--muted-color); | |
| font-size: 11px; | |
| pointer-events: none; | |
| } | |
| .d3-matrix .chart-card { | |
| background: var(--surface-bg); | |
| border: 1px solid var(--border-color); | |
| border-radius: 10px; | |
| padding: 8px; | |
| } | |
| </style> | |
| <script> | |
| (() => { | |
| // Load D3 from CDN once | |
| 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-matrix'))) { | |
| const cs = Array.from(document.querySelectorAll('.d3-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'; | |
| } | |
| // Tooltip (HTML, single instance inside container) | |
| 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; | |
| } | |
| // Panels container (two side-by-side matrices) | |
| 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); | |
| // SVG scaffolding | |
| 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'); | |
| // Demo data (two distinct 10x10 matrices: Baseline vs Improved) | |
| // Rows / Columns are generic class labels | |
| 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] | |
| ]; | |
| // Colors: sequential palette via window.ColorPalettes with graceful fallback | |
| const getSequentialColors = (count) => { | |
| try { | |
| if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') { | |
| return window.ColorPalettes.getColors('sequential', count); | |
| } | |
| } catch (_) { } | |
| // Fallback: generate a monochrome scale using the primary color with varying opacity | |
| 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 steps = Math.max(3, count | 0); | |
| const arr = []; | |
| for (let i = 0; i < steps; i++) { | |
| const t = i / (steps - 1); | |
| const pct = Math.round(t * 100); | |
| arr.push(`color-mix(in srgb, #D64545 ${100 - pct}%, #3A7BD5 ${pct}%)`); | |
| } | |
| return arr; | |
| }; | |
| let width = 800; | |
| let height = 480; | |
| const margin = { top: 36, right: 24, bottom: 26, left: 56 }; | |
| function updateSize() { | |
| const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; | |
| width = container.clientWidth || 800; | |
| const gap = 16; // matches CSS .panels gap | |
| const minPanel = 320; | |
| const nCols = (width >= (minPanel * 2 + gap)) ? 2 : 1; | |
| const panelWidth = nCols === 2 ? Math.max(minPanel, Math.floor((width - gap) / 2)) : Math.max(minPanel, width); | |
| const base = Math.max(minPanel, Math.round(panelWidth * 0.92)); | |
| height = base; | |
| // Responsive SVG: width 100%, height auto, preserve aspect via viewBox | |
| 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})`); | |
| const innerWidth = panelWidth - margin.left - margin.right; | |
| const innerHeight = height - margin.top - margin.bottom; | |
| return { innerWidth, innerHeight, isDark }; | |
| } | |
| function computeValues(normalization, matrix) { | |
| const n = classes.length; | |
| const totalsByRow = matrix.map(row => row.reduce((a, b) => a + b, 0)); | |
| const flat = []; | |
| let minV = Infinity, maxV = -Infinity; | |
| for (let r = 0; r < n; r++) { | |
| for (let c = 0; c < n; c++) { | |
| const count = matrix[r][c]; | |
| const value = normalization === 'row' ? (totalsByRow[r] ? count / totalsByRow[r] : 0) : count; | |
| if (value < minV) minV = value; | |
| if (value > maxV) maxV = value; | |
| flat.push({ r, c, count, value }); | |
| } | |
| } | |
| return { data: flat, minV, maxV }; | |
| } | |
| function getColorScale(values, minV, maxV) { | |
| // If ColorPalettes is available, use quantiles to enhance visual variation across the distribution | |
| const hasPalette = !(palette.length === 0); | |
| if (hasPalette && (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function')) { | |
| const scale = d3.scaleQuantile().domain(values).range(palette); | |
| return (v) => scale(v); | |
| } | |
| // Fallback: primary color with opacity mapped to normalized value | |
| const norm = d3.scaleLinear().domain([minV, maxV]).range([0.08, 0.9]).clamp(true); | |
| return (v) => `color-mix(in oklab, var(--primary-color) ${Math.round(norm(v) * 100)}%, var(--surface-bg))`; | |
| } | |
| // Compute a fixed readable text color from a CSS rgb()/rgba() string | |
| function chooseFixedReadableTextOnBg(bgCss) { | |
| try { | |
| const m = String(bgCss || '').match(/rgba?\(([^)]+)\)/); | |
| if (!m) return '#0e1116'; | |
| const parts = m[1].split(',').map(s => parseFloat(s.trim())); | |
| const [r, g, b] = parts; | |
| // sRGB → relative luminance | |
| const srgb = [r, g, b].map(v => Math.max(0, Math.min(255, v)) / 255); | |
| const linear = srgb.map(c => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4))); | |
| const L = 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]; | |
| // Threshold ~ 0.5 for readability; darker BG → white text, else near-black | |
| return L < 0.5 ? '#ffffff' : '#0e1116'; | |
| } catch (_) { return '#0e1116'; } | |
| } | |
| function render() { | |
| const { innerWidth, innerHeight } = updateSize(); | |
| const n = classes.length; | |
| const gridSize = Math.min(innerWidth, innerHeight); | |
| const cellSize = gridSize / n; | |
| const x = d3.scaleBand().domain(d3.range(n)).range([0, gridSize]).paddingInner(0.06); | |
| const y = d3.scaleBand().domain(d3.range(n)).range([0, gridSize]).paddingInner(0.06); | |
| // Panel A: Baseline (row-normalized) | |
| const dataA = computeValues('row', matrixA); | |
| const colorA = getColorScale(dataA.data.map(d => d.value), dataA.minV, dataA.maxV); | |
| gCellsA.selectAll('rect.cell-bg') | |
| .data([0]) | |
| .join('rect') | |
| .attr('class', 'cell-bg') | |
| .attr('x', 0) | |
| .attr('y', 0) | |
| .attr('width', gridSize) | |
| .attr('height', gridSize) | |
| .attr('fill', 'none') | |
| .attr('stroke', 'var(--border-color)') | |
| .attr('stroke-width', 1); | |
| const cellsA = gCellsA.selectAll('g.cell') | |
| .data(dataA.data, d => `${d.r}-${d.c}-A`); | |
| const cellsEnterA = cellsA.enter() | |
| .append('g') | |
| .attr('class', 'cell'); | |
| cellsEnterA.append('rect') | |
| .attr('rx', 2) | |
| .attr('ry', 2) | |
| .on('mousemove', (event, d) => { | |
| const [px, py] = 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(${px + 10}px, ${py + 10}px)`; | |
| tip.style.opacity = '1'; | |
| }) | |
| .on('mouseleave', () => { | |
| tip.style.opacity = '0'; | |
| }); | |
| cellsEnterA.append('text') | |
| .attr('class', 'cell-text') | |
| .attr('text-anchor', 'middle') | |
| .attr('dominant-baseline', 'middle'); | |
| const cellsMergedA = cellsEnterA.merge(cellsA); | |
| cellsMergedA.select('text') | |
| .attr('x', d => x(d.c) + x.bandwidth() / 2) | |
| .attr('y', d => y(d.r) + y.bandwidth() / 2) | |
| .text(d => `${Math.round(d.value * 100)}`) | |
| .style('fill', function (d) { | |
| try { | |
| const rect = this && this.parentNode ? this.parentNode.querySelector('rect') : null; | |
| const bg = rect ? getComputedStyle(rect).fill : colorA(d.value); | |
| return chooseFixedReadableTextOnBg(bg); | |
| } catch (_) { | |
| return '#0e1116'; | |
| } | |
| }); | |
| cellsMergedA.select('rect') | |
| .attr('x', d => x(d.c)) | |
| .attr('y', d => y(d.r)) | |
| .attr('width', Math.max(1, x.bandwidth())) | |
| .attr('height', Math.max(1, y.bandwidth())) | |
| .attr('fill', d => colorA(d.value)); | |
| cellsA.exit().remove(); | |
| gAxesA.selectAll('*').remove(); | |
| gAxesA.append('g') | |
| .selectAll('text') | |
| .data(classes) | |
| .join('text') | |
| .attr('class', 'axis-label') | |
| .attr('text-anchor', 'middle') | |
| .attr('x', (_, i) => x(i) + x.bandwidth() / 2) | |
| .attr('y', -8) | |
| .text(d => d); | |
| gAxesA.append('g') | |
| .selectAll('text') | |
| .data(classes) | |
| .join('text') | |
| .attr('class', 'axis-label') | |
| .attr('text-anchor', 'end') | |
| .attr('x', -8) | |
| .attr('y', (_, i) => y(i) + y.bandwidth() / 2) | |
| .attr('dominant-baseline', 'middle') | |
| .text(d => d); | |
| gAxesA.append('text') | |
| .attr('class', 'axis-label') | |
| .attr('text-anchor', 'middle') | |
| .attr('x', gridSize / 2) | |
| .attr('y', innerHeight + 20) | |
| .text('Columns'); | |
| gAxesA.append('text') | |
| .attr('class', 'axis-label') | |
| .attr('text-anchor', 'middle') | |
| .attr('transform', `translate(${-40}, ${gridSize / 2}) rotate(-90)`) | |
| .text('Rows'); | |
| // Panel B: Delta (Improved − Baseline), row-normalized differences in percentage points | |
| const dataB = computeValues('row', matrixB); | |
| const diverging = getDivergingColors(13); | |
| // Build delta values aligned to A's ordering | |
| const mapA = new Map(dataA.data.map(d => [d.r + '-' + d.c, d.value])); | |
| 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)) })); | |
| // Symmetric domain around 0 (in proportions), express later as pp in labels | |
| const maxAbsDelta = Math.max(0.01, d3.max(delta, d => Math.abs(d.value)) || 0.01); | |
| const colorB = d3.scaleQuantize().domain([-maxAbsDelta / 2, maxAbsDelta]).range(diverging); | |
| gCellsB.selectAll('rect.cell-bg') | |
| .data([0]) | |
| .join('rect') | |
| .attr('class', 'cell-bg') | |
| .attr('x', 0) | |
| .attr('y', 0) | |
| .attr('width', gridSize) | |
| .attr('height', gridSize) | |
| .attr('fill', 'none') | |
| .attr('stroke', 'var(--border-color)') | |
| .attr('stroke-width', 1); | |
| const cellsB = gCellsB.selectAll('g.cell') | |
| .data(dataB.data, d => `${d.r}-${d.c}-B`); | |
| const cellsEnterB = cellsB.enter() | |
| .append('g') | |
| .attr('class', 'cell'); | |
| cellsEnterB.append('rect') | |
| .attr('rx', 2) | |
| .attr('ry', 2) | |
| .on('mousemove', (event, d) => { | |
| const [px, py] = d3.pointer(event, container); | |
| const a = dataA.data.find(x => x.r === d.r && x.c === d.c); | |
| const b = dataB.data.find(x => x.r === d.r && x.c === d.c); | |
| const 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(${px + 10}px, ${py + 10}px)`; | |
| tip.style.opacity = '1'; | |
| }) | |
| .on('mouseleave', () => { | |
| tip.style.opacity = '0'; | |
| }); | |
| cellsEnterB.append('text') | |
| .attr('class', 'cell-text') | |
| .attr('text-anchor', 'middle') | |
| .attr('dominant-baseline', 'middle'); | |
| const cellsMergedB = cellsEnterB.merge(cellsB); | |
| cellsMergedB.select('rect') | |
| .attr('x', d => x(d.c)) | |
| .attr('y', d => y(d.r)) | |
| .attr('width', Math.max(1, x.bandwidth())) | |
| .attr('height', Math.max(1, y.bandwidth())) | |
| .attr('fill', d => colorB(delta.find(x => x.r === d.r && x.c === d.c).value)); | |
| cellsMergedB.select('text') | |
| .attr('x', d => x(d.c) + x.bandwidth() / 2) | |
| .attr('y', d => y(d.r) + y.bandwidth() / 2) | |
| .text(d => { | |
| const dv = delta.find(x => x.r === d.r && x.c === d.c).value; return `${Math.round(dv * 100)}`; | |
| }) | |
| .style('fill', function (d) { | |
| try { | |
| const rect = this && this.parentNode ? this.parentNode.querySelector('rect') : null; | |
| const dv = delta.find(x => x.r === d.r && x.c === d.c).value; | |
| const bg = rect ? getComputedStyle(rect).fill : colorB(dv); | |
| return chooseFixedReadableTextOnBg(bg); | |
| } catch (_) { | |
| return '#0e1116'; | |
| } | |
| }); | |
| cellsB.exit().remove(); | |
| gAxesB.selectAll('*').remove(); | |
| gAxesB.append('g') | |
| .selectAll('text') | |
| .data(classes) | |
| .join('text') | |
| .attr('class', 'axis-label') | |
| .attr('text-anchor', 'middle') | |
| .attr('x', (_, i) => x(i) + x.bandwidth() / 2) | |
| .attr('y', -8) | |
| .text(d => d); | |
| gAxesB.append('g') | |
| .selectAll('text') | |
| .data(classes) | |
| .join('text') | |
| .attr('class', 'axis-label') | |
| .attr('text-anchor', 'end') | |
| .attr('x', -8) | |
| .attr('y', (_, i) => y(i) + y.bandwidth() / 2) | |
| .attr('dominant-baseline', 'middle') | |
| .text(d => d); | |
| gAxesB.append('text') | |
| .attr('class', 'axis-label') | |
| .attr('text-anchor', 'middle') | |
| .attr('x', gridSize / 2) | |
| .attr('y', innerHeight + 20) | |
| .text('Columns'); | |
| gAxesB.append('text') | |
| .attr('class', 'axis-label') | |
| .attr('text-anchor', 'middle') | |
| .attr('transform', `translate(${-40}, ${gridSize / 2}) rotate(-90)`) | |
| .text('Rows'); | |
| } | |
| // Initial render + resize handling | |
| render(); | |
| const rerender = () => render(); | |
| if (window.ResizeObserver) { | |
| const ro = new ResizeObserver(() => rerender()); | |
| ro.observe(container); | |
| } else { | |
| window.addEventListener('resize', rerender); | |
| } | |
| }; | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); | |
| } else { | |
| ensureD3(bootstrap); | |
| } | |
| })(); | |
| </script> |