eiffel-tower-llama / app /src /content /embeds /d3-correlation-matrix.html
dlouapre's picture
dlouapre HF Staff
Improving charts
e0722b8
raw
history blame
17.5 kB
<div class="d3-correlation-matrix"></div>
<style>
.d3-correlation-matrix {
position: relative;
overflow: visible;
}
.d3-correlation-matrix .chart-card {
background: var(--surface-bg);
border: none;
border-radius: 10px;
padding: 0;
overflow: visible;
}
.d3-correlation-matrix .chart-card svg {
overflow: visible;
}
.d3-correlation-matrix .axis-label-x {
fill: var(--text-color);
font-size: 11px;
font-weight: 700;
}
.d3-correlation-matrix .axis-label-x text {
word-break: break-word;
white-space: normal;
}
.d3-correlation-matrix .axis-label {
fill: var(--text-color);
font-size: 11px;
font-weight: 700;
}
.d3-correlation-matrix .cell-text {
fill: var(--muted-color);
font-size: 11px;
pointer-events: none;
}
.d3-correlation-matrix .colorbar-label {
fill: var(--text-color);
font-size: 10px;
font-weight: 600;
}
</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-correlation-matrix'))){
const cs = Array.from(document.querySelectorAll('.d3-correlation-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;
}
// SVG scaffolding
const card = document.createElement('div');
card.className = 'chart-card';
container.appendChild(card);
const svg = d3.select(card).append('svg').attr('width', '100%').style('display', 'block');
const gRoot = svg.append('g');
const gCells = gRoot.append('g');
const gAxes = gRoot.append('g');
const gColorbar = gRoot.append('g');
const gColorbarRects = gColorbar.append('g'); // Separate group for colorbar rectangles
const gColorbarLabels = gColorbar.append('g'); // Separate group for colorbar labels
// Data: 6x6 correlation matrix
const metrics = [
'LLM score\nconcept',
'LLM score\ninstruction',
'LLM score\nfluency',
'Explicit\nconcept\ninclusion',
'Surprise',
'3-gram\nrepetition'
];
// Correlation matrix (exact values from the image)
const correlationMatrix = [
[1.00, -0.28, -0.37, 0.45, 0.57, 0.00],
[-0.28, 1.00, 0.80, 0.068, -0.45, -0.90],
[-0.37, 0.80, 1.00, 0.015, -0.67, -0.90],
[0.45, 0.068, 0.015, 1.00, 0.10, -0.093],
[0.57, -0.45, -0.67, 0.10, 1.00, 0.53],
[0.00, -0.90, -0.90, -0.093, 0.53, 1.00]
];
// Colors: diverging palette via window.ColorPalettes
const getDivergingColors = (count) => {
try {
if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
const palette = window.ColorPalettes.getColors('diverging', count);
// Invert the palette: reverse the array
return palette.slice().reverse();
}
} catch (_) {}
// Fallback: generate diverging scale (blue for negative, red for positive)
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);
// Blue (negative) to Red (positive) via white
if (t < 0.5) {
const bluePct = Math.round((0.5 - t) * 200);
arr.push(`color-mix(in srgb, #3A7BD5 ${bluePct}%, #ffffff ${100-bluePct}%)`);
} else {
const redPct = Math.round((t - 0.5) * 200);
arr.push(`color-mix(in srgb, #ffffff ${100-redPct}%, #D64545 ${redPct}%)`);
}
}
return arr;
};
const divergingPalette = getDivergingColors(21);
let width = 800;
let height = 600;
const margin = { top: 40, right: 0, bottom: 0, left: 70 };
const xLabelHeight = 20; // Height reserved for X-axis labels
function updateSize() {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
width = container.clientWidth || 800;
// Use more of the available width and calculate height based on content
const availableWidth = width;
const minGridSize = 400;
// Reserve space for colorbar (80px) and calculate optimal grid size
const maxGridSize = Math.min(availableWidth - margin.left - margin.right - 80, 800);
const gridSize = Math.max(minGridSize, maxGridSize);
// Height must include: top margin + grid + x labels height + extra padding to ensure visibility
height = margin.top + gridSize + xLabelHeight + 20;
// Responsive SVG: width 100%, height auto, preserve aspect via viewBox
svg
.attr('viewBox', `0 0 ${width} ${height}`)
.attr('preserveAspectRatio', 'xMidYMid meet')
.style('width', '100%')
.style('height', 'auto');
gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
return { innerWidth, innerHeight, isDark, gridSize };
}
// 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, gridSize } = updateSize();
const n = metrics.length;
const cellSize = gridSize / n;
// Ensure xLabelHeight is accessible
const labelHeight = xLabelHeight;
const x = d3.scaleBand().domain(d3.range(n)).range([0, gridSize]).paddingInner(0);
const y = d3.scaleBand().domain(d3.range(n)).range([0, gridSize]).paddingInner(0);
// Flatten correlation data
const flatData = [];
for (let r = 0; r < n; r++) {
for (let c = 0; c < n; c++) {
flatData.push({ r, c, value: correlationMatrix[r][c] });
}
}
// Color scale: diverging from -1 to 1
const colorScale = d3.scaleQuantize()
.domain([-1, 1])
.range(divergingPalette);
const cells = gCells.selectAll('g.cell')
.data(flatData, d => `${d.r}-${d.c}`);
const cellsEnter = cells.enter()
.append('g')
.attr('class', 'cell');
cellsEnter.append('rect')
.attr('rx', 0)
.attr('ry', 0)
.on('mousemove', (event, d) => {
const [px, py] = d3.pointer(event, container);
tipInner.innerHTML = `<strong>${metrics[d.r]}</strong> × <strong>${metrics[d.c]}</strong><br/>Correlation: ${d.value.toFixed(2)}`;
tip.style.transform = `translate(${px + 10}px, ${py + 10}px)`;
tip.style.opacity = '1';
})
.on('mouseleave', () => {
tip.style.opacity = '0';
});
cellsEnter.append('text')
.attr('class', 'cell-text')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle');
const cellsMerged = cellsEnter.merge(cells);
cellsMerged.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 => colorScale(d.value));
cellsMerged.select('text')
.attr('x', d => x(d.c) + x.bandwidth() / 2)
.attr('y', d => y(d.r) + y.bandwidth() / 2)
.text(d => {
if (d.value === 1.00) return '1';
const absVal = Math.abs(d.value);
if (absVal < 0.01) return '0';
return d.value.toFixed(2);
})
.style('fill', function(d){
try {
const rect = this && this.parentNode ? this.parentNode.querySelector('rect') : null;
const bg = rect ? getComputedStyle(rect).fill : colorScale(d.value);
return chooseFixedReadableTextOnBg(bg);
} catch (_) {
return '#0e1116';
}
});
cells.exit().remove();
// Create clipPath for matrix cells to preserve rounded corners
const matrixClipId = `matrix-clip-${Math.random().toString(36).slice(2)}`;
let defsMatrix = svg.select('defs');
if (defsMatrix.empty()) {
defsMatrix = svg.append('defs');
}
const matrixClipPath = defsMatrix.append('clipPath').attr('id', matrixClipId);
matrixClipPath.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', gridSize)
.attr('height', gridSize)
.attr('rx', 8)
.attr('ry', 8);
// Apply clipPath to cells group
gCells.attr('clip-path', `url(#${matrixClipId})`);
// Draw outer border with rounded corners (in a separate group above cells)
const gBorder = gRoot.append('g').attr('class', 'matrix-border');
gBorder.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('rx', 8)
.attr('ry', 8)
.attr('fill', 'none')
.attr('stroke', 'var(--border-color)')
.attr('stroke-width', 1);
// Axes labels
gAxes.selectAll('*').remove();
// X-axis labels (bottom) - using SVG text with manual line breaks
const xLabelsGroup = gAxes.append('g').attr('class', 'x-labels');
const xLabels = xLabelsGroup.selectAll('text')
.data(metrics)
.join('text')
.attr('class', 'axis-label')
.attr('text-anchor', 'middle')
.attr('x', (_, i) => x(i) + x.bandwidth() / 2)
.attr('y', gridSize + 16)
.style('font-size', '11px')
.style('font-weight', '700')
.style('fill', 'var(--text-color)')
.each(function(d) {
const text = d3.select(this);
const lines = d.split('\n');
lines.forEach((line, i) => {
text.append('tspan')
.attr('x', text.attr('x'))
.attr('dy', i === 0 ? '0' : '1.2em')
.text(line);
});
});
// Y-axis labels (left) - using SVG text with manual line breaks
const yLabelsGroup = gAxes.append('g').attr('class', 'y-labels');
const yLabels = yLabelsGroup.selectAll('text')
.data(metrics)
.join('text')
.attr('class', 'axis-label')
.attr('text-anchor', 'end')
.attr('x', -12)
.attr('y', (_, i) => y(i) + y.bandwidth() / 2)
.style('font-size', '11px')
.style('font-weight', '700')
.style('fill', 'var(--text-color)')
.each(function(d) {
const text = d3.select(this);
const lines = d.split('\n');
// Center the text vertically around the y position
const lineHeight = 1.2;
const totalHeight = (lines.length - 1) * lineHeight;
const startY = -totalHeight / 2;
lines.forEach((line, i) => {
text.append('tspan')
.attr('x', text.attr('x'))
.attr('dy', i === 0 ? startY + 'em' : lineHeight + 'em')
.attr('text-anchor', 'end')
.text(line);
});
});
// Title
gAxes.append('text')
.attr('class', 'axis-label')
.attr('text-anchor', 'middle')
.attr('x', gridSize / 2)
.attr('y', -20)
.style('font-size', '14px')
.text('Correlation Matrix');
// Colorbar
const colorbarWidth = 20;
const colorbarHeight = gridSize;
const colorbarX = gridSize + 20;
const colorbarY = 0;
const colorbarSteps = divergingPalette.length;
const colorbarRadius = 8;
// Create clipPath for rounded corners (top and bottom only)
const clipId = `colorbar-clip-${Math.random().toString(36).slice(2)}`;
let defsColorbar = svg.select('defs');
if (defsColorbar.empty()) {
defsColorbar = svg.append('defs');
}
const clipPath = defsColorbar.append('clipPath').attr('id', clipId);
clipPath.append('rect')
.attr('x', colorbarX)
.attr('y', colorbarY)
.attr('width', colorbarWidth)
.attr('height', colorbarHeight)
.attr('rx', colorbarRadius)
.attr('ry', colorbarRadius);
// Apply clipPath only to colorbar rectangles group, not labels
gColorbarRects.attr('clip-path', `url(#${clipId})`);
gColorbarRects.selectAll('rect.colorbar-rect')
.data(d3.range(colorbarSteps))
.join('rect')
.attr('class', 'colorbar-rect')
.attr('x', colorbarX)
.attr('y', (_, i) => colorbarY + (colorbarHeight / colorbarSteps) * i)
.attr('width', colorbarWidth)
.attr('height', colorbarHeight / colorbarSteps)
.attr('fill', (_, i) => divergingPalette[colorbarSteps - 1 - i])
.attr('stroke', 'none');
// Colorbar border with rounded corners (top and bottom)
gColorbarRects.append('rect')
.attr('x', colorbarX)
.attr('y', colorbarY)
.attr('width', colorbarWidth)
.attr('height', colorbarHeight)
.attr('rx', colorbarRadius)
.attr('ry', colorbarRadius)
.attr('fill', 'none')
.attr('stroke', 'var(--border-color)')
.attr('stroke-width', 1);
// Colorbar labels (outside clipPath so they're visible)
const colorbarTicks = [-1, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1];
gColorbarLabels.selectAll('text.colorbar-tick')
.data(colorbarTicks)
.join('text')
.attr('class', 'colorbar-label')
.attr('text-anchor', 'start')
.attr('x', colorbarX + colorbarWidth + 6)
.attr('y', d => colorbarY + (1 - d) / 2 * colorbarHeight)
.attr('dominant-baseline', 'middle')
.text(d => d.toFixed(2));
}
// Initial render + resize handling
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>