|
|
<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> |
|
|
(() => { |
|
|
|
|
|
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'; |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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'); |
|
|
|
|
|
|
|
|
|
|
|
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] |
|
|
]; |
|
|
|
|
|
|
|
|
const getSequentialColors = (count) => { |
|
|
try { |
|
|
if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') { |
|
|
return window.ColorPalettes.getColors('sequential', count); |
|
|
} |
|
|
} catch (_) { } |
|
|
|
|
|
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; |
|
|
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; |
|
|
|
|
|
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) { |
|
|
|
|
|
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); |
|
|
} |
|
|
|
|
|
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))`; |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
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]; |
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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'); |
|
|
|
|
|
|
|
|
const dataB = computeValues('row', matrixB); |
|
|
const diverging = getDivergingColors(13); |
|
|
|
|
|
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)) })); |
|
|
|
|
|
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'); |
|
|
} |
|
|
|
|
|
|
|
|
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> |