Molbap's picture
Molbap HF Staff
push a bunch of updates
e903a32
raw
history blame
19.9 kB
<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>