|
|
<div class="d3-bar"></div> |
|
|
<style> |
|
|
.d3-bar .controls { |
|
|
margin-top: 0; |
|
|
display: flex; |
|
|
gap: 16px; |
|
|
align-items: center; |
|
|
justify-content: flex-end; |
|
|
flex-wrap: wrap; |
|
|
} |
|
|
|
|
|
.d3-bar .controls .control-group { |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
align-items: flex-start; |
|
|
gap: 6px; |
|
|
} |
|
|
|
|
|
.d3-bar .controls label { |
|
|
font-size: 12px; |
|
|
color: var(--text-color); |
|
|
font-weight: 700; |
|
|
} |
|
|
|
|
|
.d3-bar .controls select { |
|
|
font-size: 12px; |
|
|
padding: 8px 28px 8px 10px; |
|
|
border: 1px solid var(--border-color); |
|
|
border-radius: 8px; |
|
|
background-color: var(--surface-bg); |
|
|
color: var(--text-color); |
|
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%230f1115' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); |
|
|
background-repeat: no-repeat; |
|
|
background-position: right 8px center; |
|
|
background-size: 12px; |
|
|
-webkit-appearance: none; |
|
|
-moz-appearance: none; |
|
|
appearance: none; |
|
|
cursor: pointer; |
|
|
transition: border-color .15s ease, box-shadow .15s ease; |
|
|
} |
|
|
|
|
|
[data-theme="dark"] .d3-bar .controls select { |
|
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); |
|
|
} |
|
|
|
|
|
.d3-bar .controls select:hover { |
|
|
border-color: var(--primary-color); |
|
|
} |
|
|
|
|
|
.d3-bar .controls select:focus { |
|
|
border-color: var(--primary-color); |
|
|
box-shadow: 0 0 0 3px rgba(232, 137, 171, .25); |
|
|
outline: none; |
|
|
} |
|
|
|
|
|
|
|
|
.d3-bar .chart-header { |
|
|
display: flex; |
|
|
align-items: flex-start; |
|
|
justify-content: flex-start; |
|
|
gap: 12px; |
|
|
margin: 8px 0 0 0; |
|
|
flex-wrap: wrap; |
|
|
} |
|
|
|
|
|
.d3-bar .legend-bottom { |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
align-items: flex-start; |
|
|
gap: 6px; |
|
|
font-size: 12px; |
|
|
color: var(--text-color); |
|
|
} |
|
|
|
|
|
.d3-bar .legend-bottom .legend-title { |
|
|
font-size: 12px; |
|
|
font-weight: 700; |
|
|
color: var(--text-color); |
|
|
} |
|
|
|
|
|
.d3-bar .legend-bottom .items { |
|
|
display: flex; |
|
|
flex-wrap: wrap; |
|
|
gap: 8px 14px; |
|
|
} |
|
|
|
|
|
.d3-bar .legend-bottom .item { |
|
|
display: inline-flex; |
|
|
align-items: center; |
|
|
gap: 6px; |
|
|
white-space: nowrap; |
|
|
} |
|
|
|
|
|
.d3-bar .legend-bottom .swatch { |
|
|
width: 14px; |
|
|
height: 14px; |
|
|
border-radius: 3px; |
|
|
border: 1px solid var(--border-color); |
|
|
display: inline-block; |
|
|
} |
|
|
|
|
|
.d3-bar.hovering .legend-bottom .item.ghost { |
|
|
opacity: .35; |
|
|
} |
|
|
|
|
|
.d3-bar.hovering .bars path.ghost { |
|
|
opacity: .35; |
|
|
} |
|
|
|
|
|
.d3-bar .axis-label { |
|
|
fill: var(--text-color); |
|
|
font-size: 12px; |
|
|
font-weight: 700; |
|
|
} |
|
|
|
|
|
|
|
|
.d3-bar .axes path, |
|
|
.d3-bar .axes line { |
|
|
stroke: var(--axis-color); |
|
|
} |
|
|
|
|
|
.d3-bar .axes text { |
|
|
fill: var(--tick-color); |
|
|
} |
|
|
|
|
|
.d3-bar .grid line { |
|
|
stroke: var(--grid-color); |
|
|
} |
|
|
|
|
|
|
|
|
.d3-bar .d3-tooltip { |
|
|
z-index: var(--z-tooltip); |
|
|
backdrop-filter: saturate(1.12) blur(8px); |
|
|
} |
|
|
|
|
|
|
|
|
.d3-bar .bars path.bar { |
|
|
transition: opacity .12s ease, stroke .12s ease, stroke-width .12s ease; |
|
|
} |
|
|
|
|
|
.d3-bar .bars path.bar.highlight { |
|
|
stroke: none; |
|
|
stroke-width: 0; |
|
|
} |
|
|
|
|
|
.d3-bar.hovering .bars path.ghost { |
|
|
opacity: .25; |
|
|
} |
|
|
|
|
|
.d3-bar .legend-bottom .item.hovered { |
|
|
color: inherit; |
|
|
} |
|
|
|
|
|
.d3-bar .legend-bottom .item.hovered .swatch { |
|
|
border-color: var(--border-color); |
|
|
} |
|
|
|
|
|
.d3-bar .d3-tooltip .swatch { |
|
|
width: 12px; |
|
|
height: 12px; |
|
|
border-radius: 3px; |
|
|
border: 1px solid var(--border-color); |
|
|
display: inline-block; |
|
|
margin-right: 6px; |
|
|
vertical-align: -2px; |
|
|
} |
|
|
|
|
|
|
|
|
.d3-bar .chart-card { |
|
|
background: var(--surface-bg); |
|
|
border: 1px solid var(--border-color); |
|
|
border-radius: 10px; |
|
|
padding: 8px; |
|
|
} |
|
|
|
|
|
|
|
|
.d3-bar .chart-header { |
|
|
padding-left: 8px; |
|
|
padding-right: 8px; |
|
|
gap: 20px; |
|
|
} |
|
|
|
|
|
.d3-bar .controls { |
|
|
justify-content: flex-start; |
|
|
min-width: 320px; |
|
|
} |
|
|
|
|
|
.d3-bar .controls .control-group { |
|
|
min-width: 150px; |
|
|
} |
|
|
|
|
|
.d3-bar .controls select { |
|
|
font-size: 13px; |
|
|
min-width: 160px; |
|
|
} |
|
|
</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 mount = document.currentScript ? document.currentScript.previousElementSibling : null; |
|
|
const container = (mount && mount.querySelector && mount.querySelector('.d3-bar')) || document.querySelector('.d3-bar'); |
|
|
if (!container) return; |
|
|
if (container.dataset) { if (container.dataset.mounted === 'true') return; container.dataset.mounted = 'true'; } |
|
|
|
|
|
|
|
|
const seqLabels = ["1024", "2048", "4096", "8192"]; |
|
|
const seqScale = [1, 2, 4, 8]; |
|
|
const componentKeys = ['parameters', 'gradients', 'optimizer', 'activations']; |
|
|
const modelSizes = ["1B", "3B", "8B", "70B", "405B"]; |
|
|
const paramsMem = { "1B": 4.0, "3B": 13.3, "8B": 26.0, "70B": 244.0, "405B": 1520.0 }; |
|
|
const actCoeff = { "1B": 3.6, "3B": 9.3, "8B": 46.2, "70B": 145.7, "405B": 1519.9 }; |
|
|
const recomputeModes = ["none", "selective", "full"]; |
|
|
|
|
|
const activationsCurve = (sizeKey, mode) => { |
|
|
const coeff = actCoeff[sizeKey]; |
|
|
let arr = seqScale.map((v) => coeff * (v * v)); |
|
|
if (mode === 'selective') arr = arr.map((v) => v * 0.25); |
|
|
else if (mode === 'full') arr = arr.map((v) => v * (1 / 16)); |
|
|
return arr; |
|
|
}; |
|
|
const stackFor = (sizeKey, mode) => { |
|
|
const p = seqScale.map(() => paramsMem[sizeKey]); |
|
|
const g = seqScale.map(() => paramsMem[sizeKey]); |
|
|
const o = seqScale.map(() => 2 * paramsMem[sizeKey]); |
|
|
const a = activationsCurve(sizeKey, mode); |
|
|
return { parameters: p, gradients: g, optimizer: o, activations: a }; |
|
|
}; |
|
|
|
|
|
const Y = {}; |
|
|
recomputeModes.forEach((m) => { |
|
|
Y[m] = {}; modelSizes.forEach((s) => { Y[m][s] = stackFor(s, m); }); |
|
|
}); |
|
|
|
|
|
|
|
|
const controls = document.createElement('div'); |
|
|
controls.className = 'controls'; |
|
|
const groupSize = document.createElement('div'); groupSize.className = 'control-group'; |
|
|
const labelSize = document.createElement('label'); labelSize.textContent = 'Model Size'; |
|
|
const selSize = document.createElement('select'); modelSizes.forEach((s) => { const o = document.createElement('option'); o.value = s; o.textContent = s; selSize.appendChild(o); }); |
|
|
groupSize.appendChild(labelSize); groupSize.appendChild(selSize); |
|
|
const groupRecomp = document.createElement('div'); groupRecomp.className = 'control-group'; |
|
|
const labelRecomp = document.createElement('label'); labelRecomp.textContent = 'Recomputation'; |
|
|
const selRecomp = document.createElement('select'); recomputeModes.forEach((m) => { const o = document.createElement('option'); o.value = m; o.textContent = m; selRecomp.appendChild(o); }); |
|
|
groupRecomp.appendChild(labelRecomp); groupRecomp.appendChild(selRecomp); |
|
|
|
|
|
|
|
|
const header = document.createElement('div'); header.className = 'chart-header'; |
|
|
const legendBottom = document.createElement('div'); legendBottom.className = 'legend-bottom'; |
|
|
const legendTitle = document.createElement('div'); legendTitle.className = 'legend-title'; legendTitle.textContent = 'Legend'; |
|
|
const legendItems = document.createElement('div'); legendItems.className = 'items'; |
|
|
legendBottom.appendChild(legendTitle); legendBottom.appendChild(legendItems); |
|
|
header.appendChild(legendBottom); |
|
|
header.appendChild(controls); |
|
|
|
|
|
const card = document.createElement('div'); card.className = 'chart-card'; container.appendChild(card); |
|
|
|
|
|
container.appendChild(header); |
|
|
const svg = d3.select(card).append('svg').attr('width', '100%').style('display', 'block'); |
|
|
const gRoot = svg.append('g'); |
|
|
const gGrid = gRoot.append('g').attr('class', 'grid'); |
|
|
const gAxes = gRoot.append('g').attr('class', 'axes'); |
|
|
const gBars = gRoot.append('g').attr('class', 'bars'); |
|
|
|
|
|
|
|
|
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; } |
|
|
|
|
|
|
|
|
let currentSize = modelSizes[0]; |
|
|
let currentMode = 'selective'; |
|
|
selRecomp.value = currentMode; |
|
|
|
|
|
|
|
|
let width = 800, height = 360; const margin = { top: 16, right: 28, bottom: 56, left: 64 }; |
|
|
const x0 = d3.scaleBand().paddingInner(0.25).paddingOuter(0.1); |
|
|
const y = d3.scaleLinear(); |
|
|
function getCategoricalColors(count) { |
|
|
try { |
|
|
if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') { |
|
|
return window.ColorPalettes.getColors('categorical', count); |
|
|
} |
|
|
} catch (_) { } |
|
|
const primary = getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim(); |
|
|
const tableau = (window.d3 && window.d3.schemeTableau10) ? window.d3.schemeTableau10 : ['#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f', '#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ab']; |
|
|
const pool = [primary, ...tableau]; |
|
|
const arr = []; for (let i = 0; i < count; i++) { arr.push(pool[i % pool.length]); } |
|
|
return arr; |
|
|
} |
|
|
const palette = getCategoricalColors(componentKeys.length); |
|
|
const colorMap = new Map(componentKeys.map((k, i) => [k, palette[i]])); |
|
|
const colorOf = (key) => colorMap.get(key) || 'var(--primary-color)'; |
|
|
|
|
|
function yMax(sizeKey, mode) { |
|
|
const s = Y[mode][sizeKey]; |
|
|
let max = 0; for (let i = 0; i < seqLabels.length; i++) { const sum = s.parameters[i] + s.gradients[i] + s.optimizer[i] + s.activations[i]; if (sum > max) max = sum; } |
|
|
return max * 1.05; |
|
|
} |
|
|
|
|
|
function renderLegend() { |
|
|
legendItems.innerHTML = componentKeys.map((key, i) => { |
|
|
const color = palette[i]; |
|
|
return `<span class="item" data-key="${key}"><span class=\"swatch\" style=\"background:${color}\"></span><span>${key}</span></span>`; |
|
|
}).join(''); |
|
|
legendItems.querySelectorAll('.item').forEach((el) => { |
|
|
el.addEventListener('mouseenter', () => { |
|
|
const k = el.getAttribute('data-key'); if (!k) return; |
|
|
container.classList.add('hovering'); |
|
|
gBars.selectAll('path.bar').classed('ghost', d => d && d.key !== k); |
|
|
legendItems.querySelectorAll('.item').forEach(it => it.classList.toggle('ghost', it.getAttribute('data-key') !== k)); |
|
|
}); |
|
|
el.addEventListener('mouseleave', () => { |
|
|
container.classList.remove('hovering'); |
|
|
gBars.selectAll('path.bar').classed('ghost', false); |
|
|
legendItems.querySelectorAll('.item').forEach(it => it.classList.remove('ghost')); |
|
|
}); |
|
|
}); |
|
|
} |
|
|
|
|
|
function updateScales() { |
|
|
width = container.clientWidth || 800; height = Math.max(260, Math.round(width / 3)); svg.attr('width', width).attr('height', height); |
|
|
const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom; gRoot.attr('transform', `translate(${margin.left},${margin.top})`); |
|
|
|
|
|
x0.domain(seqLabels).range([0, innerWidth]); |
|
|
y.domain([0, yMax(currentSize, currentMode)]).range([innerHeight, 0]).nice(); |
|
|
|
|
|
|
|
|
gGrid.selectAll('*').remove(); |
|
|
gGrid.selectAll('line').data(y.ticks(6)).join('line') |
|
|
.attr('x1', 0).attr('x2', innerWidth).attr('y1', (d) => y(d)).attr('y2', (d) => y(d)) |
|
|
.attr('stroke', 'var(--grid-color)').attr('stroke-width', 1).attr('shape-rendering', 'crispEdges'); |
|
|
|
|
|
|
|
|
gAxes.selectAll('*').remove(); |
|
|
gAxes.append('g').attr('transform', `translate(0,${innerHeight})`).call(d3.axisBottom(x0)).call((g) => { g.selectAll('path, line').attr('stroke', 'var(--axis-color)'); g.selectAll('text').attr('fill', 'var(--tick-color)').style('font-size', '12px'); }); |
|
|
gAxes.append('g').call(d3.axisLeft(y).ticks(6).tickFormat(d3.format('~f'))).call((g) => { g.selectAll('path, line').attr('stroke', 'var(--axis-color)'); g.selectAll('text').attr('fill', 'var(--tick-color)').style('font-size', '12px'); }); |
|
|
|
|
|
|
|
|
gAxes.append('text').attr('class', 'axis-label axis-label--x').attr('x', innerWidth / 2).attr('y', innerHeight + 44).attr('text-anchor', 'middle').text('Sequence Length'); |
|
|
gAxes.append('text').attr('class', 'axis-label axis-label--y').attr('text-anchor', 'middle').attr('transform', `translate(${-52},${innerHeight / 2}) rotate(-90)`).text('Memory (GB)'); |
|
|
|
|
|
renderLegend(); |
|
|
|
|
|
return { innerWidth, innerHeight }; |
|
|
} |
|
|
|
|
|
function drawBars() { |
|
|
const stacks = Y[currentMode][currentSize]; |
|
|
const series = componentKeys.map((key, i) => ({ key, color: palette[i], values: stacks[key] })); |
|
|
|
|
|
const stacked = seqLabels.map((label, i) => { |
|
|
let acc = 0; const items = []; |
|
|
series.forEach((s, idx) => { |
|
|
const y0 = acc; const y1 = acc + s.values[i]; |
|
|
items.push({ key: s.key, color: s.color, i, y0, y1, xLabel: label, value: s.values[i], isBottom: idx === 0, isTop: idx === series.length - 1 }); |
|
|
acc = y1; |
|
|
}); |
|
|
const total = acc; |
|
|
items.forEach(it => { it.total = total; }); |
|
|
return { label, items }; |
|
|
}); |
|
|
|
|
|
const { innerWidth, innerHeight } = updateScales(); |
|
|
|
|
|
const bandWidth = x0.bandwidth(); |
|
|
const groups = gBars.selectAll('g.bar-group').data(stacked, d => d.label); |
|
|
const groupsEnter = groups.enter().append('g').attr('class', 'bar-group'); |
|
|
groupsEnter.merge(groups).attr('transform', (d) => `translate(${x0(d.label)},0)`); |
|
|
groups.exit().remove(); |
|
|
|
|
|
|
|
|
const rCorner = 4; |
|
|
const roundedPath = (x, yTop, w, h, isTop, isBottom) => { |
|
|
const r = Math.min(rCorner, Math.max(0, Math.min(w, h) / 2)); |
|
|
const rTL = isTop ? r : 0, rTR = isTop ? r : 0, rBR = isBottom ? r : 0, rBL = isBottom ? r : 0; |
|
|
const x0 = x, y0 = yTop, x1 = x + w, y1 = yTop + h; |
|
|
return `M${x0 + rTL},${y0}` |
|
|
+ `H${x1 - rTR}` |
|
|
+ (rTR ? `Q${x1},${y0} ${x1},${y0 + rTR}` : `V${y0}`) |
|
|
+ `V${y1 - rBR}` |
|
|
+ (rBR ? `Q${x1},${y1} ${x1 - rBR},${y1}` : `H${x1}`) |
|
|
+ `H${x0 + rBL}` |
|
|
+ (rBL ? `Q${x0},${y1} ${x0},${y1 - rBL}` : `V${y1}`) |
|
|
+ `V${y0 + rTL}` |
|
|
+ (rTL ? `Q${x0},${y0} ${x0 + rTL},${y0}` : `H${x0}`) |
|
|
+ 'Z'; |
|
|
}; |
|
|
|
|
|
const bars = groupsEnter.merge(groups).selectAll('path.bar').data(d => d.items, d => d.key); |
|
|
bars.enter().append('path').attr('class', 'bar') |
|
|
.attr('d', (d) => roundedPath(0, y(d.y1), bandWidth, Math.max(0.5, y(d.y0) - y(d.y1)), d.isTop, d.isBottom)) |
|
|
.attr('fill', (d) => d.color) |
|
|
.on('mouseenter', function (ev, d) { |
|
|
container.classList.add('hovering'); |
|
|
gBars.selectAll('path.bar').classed('ghost', (dd) => !(dd && dd.key === d.key)); |
|
|
const pct = d.total > 0 ? (d.value / d.total * 100) : 0; |
|
|
tipInner.innerHTML = ` |
|
|
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;"> |
|
|
<span class="swatch" style="background:${d.color}"></span> |
|
|
<strong>${d.key}</strong> |
|
|
</div> |
|
|
<div><strong>Seq</strong> ${d.xLabel}</div> |
|
|
<div><strong>Mem</strong> ${d.value.toFixed(1)} GB <span style="opacity:.7">(${pct.toFixed(0)}%)</span></div> |
|
|
<div style="opacity:.7"><strong>Total</strong> ${d.total.toFixed(1)} GB</div> |
|
|
`; |
|
|
tip.style.opacity = '1'; |
|
|
const li = legendItems.querySelector(`.item[data-key="${d.key}"]`); |
|
|
if (li) li.classList.add('hovered'); |
|
|
legendItems.querySelectorAll('.item').forEach(it => it.classList.toggle('ghost', it.getAttribute('data-key') !== d.key)); |
|
|
}) |
|
|
.on('mousemove', function (ev, d) { |
|
|
const [mx, my] = d3.pointer(ev, container); |
|
|
const offsetX = 12, offsetY = 12; |
|
|
const maxX = (container.clientWidth || 0) - (tip.offsetWidth + 6); |
|
|
const maxY = (container.clientHeight || 0) - (tip.offsetHeight + 6); |
|
|
const tx = Math.max(0, Math.min(mx + offsetX, maxX)); |
|
|
const ty = Math.max(0, Math.min(my + offsetY, maxY)); |
|
|
tip.style.transform = `translate(${Math.round(tx)}px, ${Math.round(ty)}px)`; |
|
|
}) |
|
|
.on('mouseleave', function () { |
|
|
tip.style.opacity = '0'; |
|
|
tip.style.transform = 'translate(-9999px, -9999px)'; |
|
|
container.classList.remove('hovering'); |
|
|
gBars.selectAll('path.bar').classed('ghost', false).classed('highlight', false); |
|
|
legendItems.querySelectorAll('.item').forEach(it => { it.classList.remove('hovered'); it.classList.remove('ghost'); }); |
|
|
}) |
|
|
.merge(bars) |
|
|
.transition().duration(200) |
|
|
.attr('d', (d) => roundedPath(0, y(d.y1), bandWidth, Math.max(0.5, y(d.y0) - y(d.y1)), d.isTop, d.isBottom)) |
|
|
.attr('fill', (d) => d.color); |
|
|
bars.exit().remove(); |
|
|
} |
|
|
|
|
|
function update() { drawBars(); } |
|
|
|
|
|
|
|
|
update(); |
|
|
|
|
|
controls.appendChild(groupSize); controls.appendChild(groupRecomp); |
|
|
selSize.addEventListener('change', (e) => { currentSize = e.target.value; update(); }); |
|
|
selRecomp.addEventListener('change', (e) => { currentMode = e.target.value; update(); }); |
|
|
|
|
|
const rerender = () => { update(); }; |
|
|
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> |