|
|
<div class="d3-precision-recall"></div> |
|
|
|
|
|
<style> |
|
|
.d3-precision-recall { |
|
|
font-family: var(--default-font-family); |
|
|
background: transparent; |
|
|
border: none; |
|
|
border-radius: 0; |
|
|
padding: var(--spacing-4) 0; |
|
|
width: 100%; |
|
|
margin: 0 auto; |
|
|
} |
|
|
|
|
|
.d3-precision-recall svg { |
|
|
width: 100%; |
|
|
height: auto; |
|
|
display: block; |
|
|
} |
|
|
|
|
|
.d3-precision-recall .circle { |
|
|
fill: none; |
|
|
stroke-width: 3; |
|
|
opacity: 0.8; |
|
|
} |
|
|
|
|
|
.d3-precision-recall .circle-predicted { |
|
|
stroke: #4A90E2; |
|
|
fill: #4A90E2; |
|
|
fill-opacity: 0.15; |
|
|
} |
|
|
|
|
|
.d3-precision-recall .circle-relevant { |
|
|
stroke: #F5A623; |
|
|
fill: #F5A623; |
|
|
fill-opacity: 0.15; |
|
|
} |
|
|
|
|
|
.d3-precision-recall .intersection { |
|
|
fill: #7ED321; |
|
|
fill-opacity: 0.3; |
|
|
} |
|
|
|
|
|
[data-theme="dark"] .d3-precision-recall .circle-predicted { |
|
|
stroke: #5DA9FF; |
|
|
fill: #5DA9FF; |
|
|
} |
|
|
|
|
|
[data-theme="dark"] .d3-precision-recall .circle-relevant { |
|
|
stroke: #FFB84D; |
|
|
fill: #FFB84D; |
|
|
} |
|
|
|
|
|
[data-theme="dark"] .d3-precision-recall .intersection { |
|
|
fill: #94E842; |
|
|
} |
|
|
|
|
|
.d3-precision-recall .label { |
|
|
font-size: 14px; |
|
|
font-weight: 600; |
|
|
fill: var(--text-color); |
|
|
} |
|
|
|
|
|
.d3-precision-recall .count-label { |
|
|
font-size: 13px; |
|
|
font-weight: 500; |
|
|
fill: var(--text-color); |
|
|
} |
|
|
|
|
|
.d3-precision-recall .formula-text { |
|
|
font-size: 12px; |
|
|
fill: var(--text-color); |
|
|
} |
|
|
|
|
|
.d3-precision-recall .formula-box { |
|
|
fill: var(--surface-bg); |
|
|
stroke: var(--border-color); |
|
|
stroke-width: 1; |
|
|
} |
|
|
|
|
|
.d3-precision-recall .section-title { |
|
|
font-size: 16px; |
|
|
font-weight: 700; |
|
|
fill: var(--primary-color); |
|
|
text-anchor: middle; |
|
|
} |
|
|
|
|
|
.d3-precision-recall .legend-text { |
|
|
font-size: 11px; |
|
|
fill: var(--text-color); |
|
|
} |
|
|
|
|
|
.d3-precision-recall .legend-rect { |
|
|
stroke-width: 1.5; |
|
|
} |
|
|
</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-precision-recall'))) { |
|
|
const candidates = Array.from(document.querySelectorAll('.d3-precision-recall')) |
|
|
.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'; |
|
|
} |
|
|
|
|
|
const svg = d3.select(container).append('svg'); |
|
|
const g = svg.append('g'); |
|
|
|
|
|
let width = 800; |
|
|
let height = 500; |
|
|
|
|
|
function render() { |
|
|
width = container.clientWidth || 800; |
|
|
height = Math.max(400, Math.round(width * 0.5)); |
|
|
|
|
|
svg.attr('width', width).attr('height', height); |
|
|
|
|
|
const margin = { top: 40, right: 40, bottom: 80, left: 40 }; |
|
|
const innerWidth = width - margin.left - margin.right; |
|
|
const innerHeight = height - margin.top - margin.bottom; |
|
|
|
|
|
g.attr('transform', `translate(${margin.left},${margin.top})`); |
|
|
g.selectAll('*').remove(); |
|
|
|
|
|
|
|
|
const TP = 45; |
|
|
const FP = 8; |
|
|
const FN = 5; |
|
|
|
|
|
const totalPredicted = TP + FP; |
|
|
const totalRelevant = TP + FN; |
|
|
|
|
|
const precision = TP / totalPredicted; |
|
|
const recall = TP / totalRelevant; |
|
|
|
|
|
|
|
|
const radius = Math.min(innerWidth, innerHeight) * 0.25; |
|
|
const overlapOffset = radius * 0.6; |
|
|
|
|
|
const predictedX = innerWidth * 0.35; |
|
|
const relevantX = predictedX + overlapOffset; |
|
|
const centerY = innerHeight * 0.4; |
|
|
|
|
|
|
|
|
g.append('text') |
|
|
.attr('class', 'section-title') |
|
|
.attr('x', innerWidth / 2) |
|
|
.attr('y', -10) |
|
|
.text('Precision and Recall Visualization'); |
|
|
|
|
|
|
|
|
g.append('circle') |
|
|
.attr('class', 'circle circle-predicted') |
|
|
.attr('cx', predictedX) |
|
|
.attr('cy', centerY) |
|
|
.attr('r', radius); |
|
|
|
|
|
g.append('circle') |
|
|
.attr('class', 'circle circle-relevant') |
|
|
.attr('cx', relevantX) |
|
|
.attr('cy', centerY) |
|
|
.attr('r', radius); |
|
|
|
|
|
|
|
|
const intersectionX = (predictedX + relevantX) / 2; |
|
|
|
|
|
|
|
|
const clipId = 'clip-intersection-' + Math.random().toString(36).substr(2, 9); |
|
|
const defs = g.append('defs'); |
|
|
const clipPath = defs.append('clipPath').attr('id', clipId); |
|
|
|
|
|
clipPath.append('circle') |
|
|
.attr('cx', predictedX) |
|
|
.attr('cy', centerY) |
|
|
.attr('r', radius); |
|
|
|
|
|
g.append('circle') |
|
|
.attr('class', 'intersection') |
|
|
.attr('cx', relevantX) |
|
|
.attr('cy', centerY) |
|
|
.attr('r', radius) |
|
|
.attr('clip-path', `url(#${clipId})`); |
|
|
|
|
|
|
|
|
g.append('text') |
|
|
.attr('class', 'label') |
|
|
.attr('x', predictedX - radius * 0.7) |
|
|
.attr('y', centerY - radius - 15) |
|
|
.attr('text-anchor', 'middle') |
|
|
.text('Predicted Correct'); |
|
|
|
|
|
g.append('text') |
|
|
.attr('class', 'label') |
|
|
.attr('x', relevantX + radius * 0.7) |
|
|
.attr('y', centerY - radius - 15) |
|
|
.attr('text-anchor', 'middle') |
|
|
.text('Actually Correct'); |
|
|
|
|
|
|
|
|
|
|
|
g.append('text') |
|
|
.attr('class', 'count-label') |
|
|
.attr('x', predictedX - radius * 0.5) |
|
|
.attr('y', centerY) |
|
|
.attr('text-anchor', 'middle') |
|
|
.attr('fill', '#4A90E2') |
|
|
.text(`FP: ${FP}`); |
|
|
|
|
|
|
|
|
g.append('text') |
|
|
.attr('class', 'count-label') |
|
|
.attr('x', intersectionX) |
|
|
.attr('y', centerY) |
|
|
.attr('text-anchor', 'middle') |
|
|
.attr('fill', '#7ED321') |
|
|
.style('font-weight', '700') |
|
|
.text(`TP: ${TP}`); |
|
|
|
|
|
|
|
|
g.append('text') |
|
|
.attr('class', 'count-label') |
|
|
.attr('x', relevantX + radius * 0.5) |
|
|
.attr('y', centerY) |
|
|
.attr('text-anchor', 'middle') |
|
|
.attr('fill', '#F5A623') |
|
|
.text(`FN: ${FN}`); |
|
|
|
|
|
|
|
|
const formulaY = centerY + radius + 60; |
|
|
const boxWidth = Math.min(200, innerWidth * 0.35); |
|
|
const boxHeight = 80; |
|
|
const boxGap = 40; |
|
|
|
|
|
const precisionX = innerWidth * 0.3 - boxWidth / 2; |
|
|
const recallX = innerWidth * 0.7 - boxWidth / 2; |
|
|
|
|
|
|
|
|
g.append('rect') |
|
|
.attr('class', 'formula-box') |
|
|
.attr('x', precisionX) |
|
|
.attr('y', formulaY - boxHeight / 2) |
|
|
.attr('width', boxWidth) |
|
|
.attr('height', boxHeight) |
|
|
.attr('rx', 8); |
|
|
|
|
|
g.append('text') |
|
|
.attr('class', 'label') |
|
|
.attr('x', precisionX + boxWidth / 2) |
|
|
.attr('y', formulaY - boxHeight / 2 + 20) |
|
|
.attr('text-anchor', 'middle') |
|
|
.attr('fill', '#4A90E2') |
|
|
.text('Precision'); |
|
|
|
|
|
g.append('text') |
|
|
.attr('class', 'formula-text') |
|
|
.attr('x', precisionX + boxWidth / 2) |
|
|
.attr('y', formulaY - boxHeight / 2 + 40) |
|
|
.attr('text-anchor', 'middle') |
|
|
.text(`TP / (TP + FP) = ${TP} / ${totalPredicted}`); |
|
|
|
|
|
g.append('text') |
|
|
.attr('class', 'formula-text') |
|
|
.attr('x', precisionX + boxWidth / 2) |
|
|
.attr('y', formulaY - boxHeight / 2 + 60) |
|
|
.attr('text-anchor', 'middle') |
|
|
.style('font-weight', '700') |
|
|
.style('font-size', '16px') |
|
|
.attr('fill', '#4A90E2') |
|
|
.text(`= ${(precision * 100).toFixed(1)}%`); |
|
|
|
|
|
|
|
|
g.append('rect') |
|
|
.attr('class', 'formula-box') |
|
|
.attr('x', recallX) |
|
|
.attr('y', formulaY - boxHeight / 2) |
|
|
.attr('width', boxWidth) |
|
|
.attr('height', boxHeight) |
|
|
.attr('rx', 8); |
|
|
|
|
|
g.append('text') |
|
|
.attr('class', 'label') |
|
|
.attr('x', recallX + boxWidth / 2) |
|
|
.attr('y', formulaY - boxHeight / 2 + 20) |
|
|
.attr('text-anchor', 'middle') |
|
|
.attr('fill', '#F5A623') |
|
|
.text('Recall'); |
|
|
|
|
|
g.append('text') |
|
|
.attr('class', 'formula-text') |
|
|
.attr('x', recallX + boxWidth / 2) |
|
|
.attr('y', formulaY - boxHeight / 2 + 40) |
|
|
.attr('text-anchor', 'middle') |
|
|
.text(`TP / (TP + FN) = ${TP} / ${totalRelevant}`); |
|
|
|
|
|
g.append('text') |
|
|
.attr('class', 'formula-text') |
|
|
.attr('x', recallX + boxWidth / 2) |
|
|
.attr('y', formulaY - boxHeight / 2 + 60) |
|
|
.attr('text-anchor', 'middle') |
|
|
.style('font-weight', '700') |
|
|
.style('font-size', '16px') |
|
|
.attr('fill', '#F5A623') |
|
|
.text(`= ${(recall * 100).toFixed(1)}%`); |
|
|
} |
|
|
|
|
|
render(); |
|
|
|
|
|
|
|
|
if (window.ResizeObserver) { |
|
|
const ro = new ResizeObserver(() => render()); |
|
|
ro.observe(container); |
|
|
} else { |
|
|
window.addEventListener('resize', render); |
|
|
} |
|
|
}; |
|
|
|
|
|
if (document.readyState === 'loading') { |
|
|
document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); |
|
|
} else { |
|
|
ensureD3(bootstrap); |
|
|
} |
|
|
})(); |
|
|
</script> |
|
|
|