Spaces:
Running
Converting Static Figures to Interactive D3 Charts
This guide explains how to convert PNG figures into interactive D3.js visualizations for this project.
Overview
Each interactive chart consists of:
- JSON data file in
app/public/data/(served at/data/filename.json) - HTML embed file in
app/src/content/embeds/(e.g.,chart-name.html) - MDX integration using the
HtmlEmbedcomponent
File Structure
app/
βββ public/data/ # JSON data (served at /data/*)
β βββ overall_performance.json
β βββ calibration_curves.json
β βββ ...
βββ src/content/embeds/ # HTML chart implementations
βββ banner.html # Example: scatter plot
βββ calibration-curves.html # (to create)
Step 1: Understand Your Data
Check the JSON structure in app/public/data/. Common patterns:
Scatter plot (overall_performance.json):
{
"models": [
{ "name": "Model A", "avg_score": 15.8, "avg_output_tokens_per_turn": 5253, "color": "#FF6B00", "is_open": false }
]
}
Line chart / Calibration (calibration_curves.json):
{
"models": [
{
"name": "Model A", "color": "#FF6B00",
"calibration_points": [
{ "confidence_level": 5, "actual_success_rate": 0.041, "sample_count": 73 }
]
}
]
}
Histogram (confidence_distribution.json):
{
"models": [
{
"name": "Model A", "color": "#FF6B00", "total_guesses": 579,
"distribution": [
{ "confidence_level": 5, "proportion": 0.024, "count": 14 }
]
}
]
}
Step 2: Create the HTML Embed
Create a new file in app/src/content/embeds/. Use this template:
<div class="d3-CHART-NAME"></div>
<style>
/* Scoped styles - prefix everything with .d3-CHART-NAME */
.d3-CHART-NAME {
width: 100%;
margin: 10px 0;
position: relative;
font-family: system-ui, -apple-system, sans-serif;
}
.d3-CHART-NAME svg {
display: block;
width: 100%;
height: auto;
}
/* Use CSS variables for theme support */
.d3-CHART-NAME .axes path,
.d3-CHART-NAME .axes line {
stroke: var(--axis-color, var(--text-color));
}
.d3-CHART-NAME .axes text {
fill: var(--tick-color, var(--muted-color));
font-size: 11px;
}
.d3-CHART-NAME .grid line {
stroke: var(--grid-color, rgba(0,0,0,.08));
}
/* Use specific selector to override .axes text */
.d3-CHART-NAME .axes text.axis-label {
font-size: 14px;
font-weight: 500;
fill: var(--text-color);
}
.d3-CHART-NAME .axes text.chart-title {
font-size: 16px;
font-weight: 600;
fill: var(--text-color);
}
/* Adjust tick label spacing if needed */
.d3-CHART-NAME .x-axis text {
transform: translateY(4px);
}
/* Tooltip */
.d3-CHART-NAME .d3-tooltip {
position: absolute;
top: 0; left: 0;
transform: translate(-9999px, -9999px);
pointer-events: none;
padding: 10px 12px;
border-radius: 8px;
font-size: 12px;
line-height: 1.4;
border: 1px solid var(--border-color);
background: var(--surface-bg);
color: var(--text-color);
box-shadow: 0 4px 24px rgba(0,0,0,.18);
opacity: 0;
transition: opacity 0.12s ease;
z-index: 10;
}
</style>
<script>
(() => {
// D3 loader - reuses existing if already loaded
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 = () => {
// Find container (handles multiple instances)
const scriptEl = document.currentScript;
let container = scriptEl ? scriptEl.previousElementSibling : null;
if (!(container && container.classList && container.classList.contains('d3-CHART-NAME'))) {
const candidates = Array.from(document.querySelectorAll('.d3-CHART-NAME'))
.filter((el) => !(el.dataset && el.dataset.mounted === 'true'));
container = candidates[candidates.length - 1] || null;
}
if (!container) return;
if (container.dataset) {
if (container.dataset.mounted === 'true') return;
container.dataset.mounted = 'true';
}
// Tooltip setup
container.style.position = container.style.position || 'relative';
const tip = document.createElement('div');
tip.className = 'd3-tooltip';
container.appendChild(tip);
// SVG setup
const svg = d3.select(container).append('svg');
const gRoot = svg.append('g');
// Chart groups (order matters for layering)
const gGrid = gRoot.append('g').attr('class', 'grid');
const gAxes = gRoot.append('g').attr('class', 'axes');
const gContent = gRoot.append('g').attr('class', 'content');
// State
let data = null;
let width = 800;
let height = 450;
const margin = { top: 40, right: 120, bottom: 56, left: 72 };
// Scales
const xScale = d3.scaleLinear();
const yScale = d3.scaleLinear();
// Data loading - single path since we use public/data/
const DATA_URL = '/data/YOUR_DATA_FILE.json';
function updateSize() {
width = container.clientWidth || 800;
height = Math.max(300, Math.round(width / 1.78)); // 16:9 aspect ratio
svg.attr('width', width).attr('height', height).attr('viewBox', `0 0 ${width} ${height}`);
gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
return {
innerWidth: width - margin.left - margin.right,
innerHeight: height - margin.top - margin.bottom
};
}
function showTooltip(event, d) {
const rect = container.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
tip.innerHTML = `
<div style="font-weight: 600; color: ${d.color}">${d.name}</div>
<div>Value: ${d.value}</div>
`;
const tipWidth = tip.offsetWidth || 150;
const tipHeight = tip.offsetHeight || 80;
let tipX = x + 12;
let tipY = y - tipHeight / 2;
if (tipX + tipWidth > width) tipX = x - tipWidth - 12;
if (tipY < 0) tipY = 8;
if (tipY + tipHeight > height) tipY = height - tipHeight - 8;
tip.style.transform = `translate(${tipX}px, ${tipY}px)`;
tip.style.opacity = '1';
}
function hideTooltip() {
tip.style.opacity = '0';
tip.style.transform = 'translate(-9999px, -9999px)';
}
function render() {
if (!data) return;
const { innerWidth, innerHeight } = updateSize();
// TODO: Implement your chart rendering here
// - Update scales with data extent
// - Draw grid lines
// - Draw axes
// - Draw data elements (lines, bars, points, etc.)
}
// Initialize
fetch(DATA_URL, { cache: 'no-cache' })
.then(r => r.json())
.then(json => {
data = json;
render();
})
.catch(err => {
const pre = document.createElement('pre');
pre.style.color = 'red';
pre.style.padding = '16px';
pre.textContent = `Error loading data: ${err.message}`;
container.appendChild(pre);
});
// Resize handling
if (window.ResizeObserver) {
new ResizeObserver(() => render()).observe(container);
} else {
window.addEventListener('resize', render);
}
// Theme change handling (re-render on light/dark toggle)
const observer = new MutationObserver(() => render());
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme']
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
} else {
ensureD3(bootstrap);
}
})();
</script>
Step 3: Key Implementation Details
CSS Variables (Theme Support)
Always use CSS variables for colors that need to adapt to light/dark mode:
| Variable | Purpose |
|---|---|
var(--text-color) |
Main text, labels |
var(--muted-color) |
Secondary text, tick labels |
var(--border-color) |
Borders, outlines |
var(--surface-bg) |
Tooltip background |
var(--page-bg) |
Page background |
D3 Patterns Used
Scale setup:
const xExtent = d3.extent(data, d => d.x);
const xPadding = (xExtent[1] - xExtent[0]) * 0.1;
xScale.domain([xExtent[0] - xPadding, xExtent[1] + xPadding])
.range([0, innerWidth])
.nice();
Grid lines:
gGrid.selectAll('.grid-x')
.data(xScale.ticks(6))
.join('line')
.attr('class', 'grid-x')
.attr('x1', d => xScale(d))
.attr('x2', d => xScale(d))
.attr('y1', 0)
.attr('y2', innerHeight);
Axes (basic):
gAxes.selectAll('.x-axis')
.data([0])
.join('g')
.attr('class', 'x-axis')
.attr('transform', `translate(0,${innerHeight})`)
.call(d3.axisBottom(xScale).ticks(6));
Axes with inner ticks:
const tickSize = 6;
gAxes.selectAll('.x-axis')
.data([0])
.join('g')
.attr('class', 'x-axis')
.attr('transform', `translate(0,${innerHeight})`)
.call(d3.axisBottom(xScale)
.ticks(6)
.tickSizeInner(-tickSize) // Negative = ticks point inward
.tickSizeOuter(0)); // No outer ticks
Custom shapes (5-point star):
const starPath = (cx, cy, outerR, innerR) => {
const points = [];
for (let i = 0; i < 10; i++) {
const r = i % 2 === 0 ? outerR : innerR;
const angle = (Math.PI / 2) + (i * Math.PI / 5);
points.push([cx + r * Math.cos(angle), cy - r * Math.sin(angle)]);
}
return 'M' + points.map(p => p.join(',')).join('L') + 'Z';
};
// Use with path elements
gContent.selectAll('.point-star')
.data(openModels)
.join('path')
.attr('d', d => starPath(xScale(d.x), yScale(d.y), radius * 1.2, radius * 0.5))
.attr('fill', d => d.color);
Data-join for elements:
gContent.selectAll('.point')
.data(models)
.join('circle')
.attr('class', 'point')
.attr('cx', d => xScale(d.x))
.attr('cy', d => yScale(d.y))
.attr('r', 8)
.attr('fill', d => d.color)
.on('mouseenter', showTooltip)
.on('mousemove', showTooltip)
.on('mouseleave', hideTooltip);
Step 4: Integrate in MDX
In your .mdx file:
import HtmlEmbed from "../../../components/HtmlEmbed.astro";
<HtmlEmbed
src="chart-name.html"
title="Chart Title"
caption="<strong>Figure N:</strong> Description of what this shows."
/>
For frameless embedding (like the banner):
<HtmlEmbed src="banner.html" frameless />
Charts to Convert
| Figure | Data File | Chart Type | Status |
|---|---|---|---|
| 1 | overall_performance.json |
Scatter | Done (banner.html) |
| 2 | calibration_curves.json |
Multi-line | Done (calibration-curves.html) |
| 3 | confidence_distribution.json |
Grouped histogram | Done (confidence-distribution.html) |
| 4 | score_vs_failed_guesses.json |
Scatter | TODO |
| 5 | excess_caution.json |
Box plot | TODO |
| 5b | tokens_by_turn.json |
Multi-line | Done (tokens-by-turn.html) |
| 6 | caution_vs_failed_guesses.json |
Scatter | Done (caution-vs-failed-guesses.html) |
| 7 | by_rule.json |
Strip plot | Done (by-rule.html) |
| 8 | complexity_analysis.json |
Heatmap | Done (complexity-analysis.html) |
| 9 | complexity_ratio.json |
Horizontal dot plot | Done (complexity-ratio.html) |
Testing
- Run dev server:
cd app && npm run dev - Check the chart loads at the correct URL
- Verify tooltip interactions
- Toggle light/dark mode to check theme support
- Resize the window to verify responsiveness
Debugging Tips
- Open browser console to see data loading errors
- Check Network tab to verify
/data/filename.jsonis being fetched - If chart doesn't render, check
container.dataset.mountedisn't already 'true' - CSS scoping: always prefix selectors with
.d3-CHART-NAME
Common Gotchas
Using .style() vs .attr() for Dynamic Colors
When setting fill/stroke colors dynamically in D3 based on data, use .style() instead of .attr():
// WON'T WORK - attr has lower specificity than CSS rules
.attr('fill', d => getContrastColor(d.color))
// USE THIS - inline styles have higher specificity
.style('fill', d => getContrastColor(d.color))
This is especially important for text labels where you need to calculate contrast colors dynamically. Example contrast function:
function getContrastColor(hexColor) {
const hex = hexColor.replace('#', '');
const r = parseInt(hex.substr(0, 2), 16) / 255;
const g = parseInt(hex.substr(2, 2), 16) / 255;
const b = parseInt(hex.substr(4, 2), 16) / 255;
const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
return luminance > 0.5 ? '#000000' : '#ffffff';
}
// Usage
gLabels.selectAll('.label')
.data(items)
.join('text')
.style('fill', d => getContrastColor(d.color))
.text(d => d.name);
CSS Specificity for Axis Labels
The generic .axes text rule applies to ALL text inside the axes group, including axis labels. To style axis labels differently, use a more specific selector:
/* This won't work - gets overridden by .axes text */
.d3-CHART-NAME .axis-label {
font-size: 15px;
}
/* Use this instead - more specific */
.d3-CHART-NAME .axes text.axis-label {
font-size: 15px;
font-weight: 500;
fill: var(--text-color);
}
Adjusting Tick Label Position
To move X-axis tick labels down (add spacing from the axis line):
.d3-CHART-NAME .x-axis text {
transform: translateY(4px);
}
Removing Chart Elements
When you don't need a title or legend:
- Remove the rendering code from
render() - Remove the CSS styles
- Adjust margins accordingly (e.g., reduce
margin.topif no title)