lerobot/robot-learning-tutorial
508
1<div class="d3-robot-arm" style="width:100%;margin:10px 0;aspect-ratio:3/1;min-height:260px;"></div>2<script>3 (() => {4 const ensureD3 = (cb) => {5 if (window.d3 && typeof window.d3.select === 'function') return cb();6 let s = document.getElementById('d3-cdn-script');7 if (!s) {8 s = document.createElement('script');9 s.id = 'd3-cdn-script';10 s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js';11 document.head.appendChild(s);12 }13 const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };14 s.addEventListener('load', onReady, { once: true });15 if (window.d3) onReady();16 };17 18 const bootstrap = () => {19 const mount = document.currentScript ? document.currentScript.previousElementSibling : null;20 const container = (mount && mount.querySelector && mount.querySelector('.d3-robot-arm')) || document.querySelector('.d3-robot-arm');21 if (!container) return;22 if (container.dataset) {23 if (container.dataset.mounted === 'true') return;24 container.dataset.mounted = 'true';25 }26 27 // Robot arm parameters28 const armLengths = [120, 100, 80]; // 3 segments29 const numSegments = armLengths.length;30 31 // Trail for end effector32 const trailLength = 80;33 const trail = [];34 35 // Animation state36 let targetX = 0, targetY = 0;37 let currentX = 0, currentY = 0; // Smooth interpolated position38 let time = 0;39 let prevPositions = null;40 41 // Task system42 let currentTask = 0;43 let taskProgress = 0;44 let gripperOpen = true;45 let gripperOpenness = 1.0; // 0 = closed, 1 = open (for smooth animation)46 let heldObject = null;47 48 // Objects to manipulate with initial and target positions49 const objects = [50 { id: 1, x: 0, y: 0, targetX: 0, targetY: 0, size: 25, color: 0.2, label: 'Cube A', shape: 'square', placed: false },51 { id: 2, x: 0, y: 0, targetX: 0, targetY: 0, size: 20, color: 0.5, label: 'Ball B', shape: 'circle', placed: false },52 { id: 3, x: 0, y: 0, targetX: 0, targetY: 0, size: 22, color: 0.8, label: 'Cube C', shape: 'square', placed: false }53 ];54 55 // Task sequences optimized for smooth reach56 const tasks = [57 { type: 'pick', objectId: 1, duration: 100 },58 { type: 'move', x: 0.3, y: 0.5, duration: 90 },59 { type: 'place', duration: 60 },60 { type: 'return', duration: 80 },61 { type: 'pick', objectId: 2, duration: 100 },62 { type: 'move', x: 0.5, y: 0.6, duration: 90 },63 { type: 'place', duration: 60 },64 { type: 'return', duration: 80 },65 { type: 'pick', objectId: 3, duration: 100 },66 { type: 'move', x: 0.7, y: 0.5, duration: 90 },67 { type: 'place', duration: 60 },68 { type: 'idle', duration: 100 }69 ];70 71 // FABRIK Inverse Kinematics solver (Forward And Backward Reaching)72 const solveIK = (targetX, targetY, baseX, baseY) => {73 // Initialize joint positions (use previous if available, otherwise extend horizontally)74 let positions;75 76 if (prevPositions && prevPositions.length === numSegments + 1) {77 // Use previous positions as starting point for smooth animation78 positions = prevPositions.map(p => ({ x: p.x, y: p.y }));79 positions[0] = { x: baseX, y: baseY }; // Always fix base80 } else {81 // Initial position: extend arm horizontally to the right82 positions = [{ x: baseX, y: baseY }];83 let x = baseX, y = baseY;84 for (let i = 0; i < numSegments; i++) {85 x += armLengths[i];86 positions.push({ x, y });87 }88 }89 90 // FABRIK iterations91 const maxIterations = 10;92 const tolerance = 0.1;93 94 for (let iter = 0; iter < maxIterations; iter++) {95 // Forward reaching: start from end effector96 positions[numSegments].x = targetX;97 positions[numSegments].y = targetY;98 99 for (let i = numSegments - 1; i >= 0; i--) {100 const dx = positions[i].x - positions[i + 1].x;101 const dy = positions[i].y - positions[i + 1].y;102 const dist = Math.sqrt(dx * dx + dy * dy);103 104 if (dist > 0) {105 const lambda = armLengths[i] / dist;106 positions[i].x = positions[i + 1].x + dx * lambda;107 positions[i].y = positions[i + 1].y + dy * lambda;108 }109 }110 111 // Backward reaching: start from base112 positions[0].x = baseX;113 positions[0].y = baseY;114 115 for (let i = 0; i < numSegments; i++) {116 const dx = positions[i + 1].x - positions[i].x;117 const dy = positions[i + 1].y - positions[i].y;118 const dist = Math.sqrt(dx * dx + dy * dy);119 120 if (dist > 0) {121 const lambda = armLengths[i] / dist;122 positions[i + 1].x = positions[i].x + dx * lambda;123 positions[i + 1].y = positions[i].y + dy * lambda;124 }125 }126 127 // Check convergence128 const endDx = positions[numSegments].x - targetX;129 const endDy = positions[numSegments].y - targetY;130 const endDist = Math.sqrt(endDx * endDx + endDy * endDy);131 132 if (endDist < tolerance) break;133 }134 135 // Calculate angles from positions136 const angles = [];137 for (let i = 0; i < numSegments; i++) {138 const dx = positions[i + 1].x - positions[i].x;139 const dy = positions[i + 1].y - positions[i].y;140 angles.push(Math.atan2(dy, dx));141 }142 143 // Save positions for next iteration144 prevPositions = positions;145 146 return { angles, positions };147 };148 149 // Colors150 const c0 = d3.rgb(78, 165, 183);151 const c1 = d3.rgb(206, 192, 250);152 const c2 = d3.rgb(232, 137, 171);153 const interp01 = d3.interpolateRgb(c0, c1);154 const interp12 = d3.interpolateRgb(c1, c2);155 const colorFor = (v) => {156 const t = Math.max(0, Math.min(1, v));157 return t <= 0.5 ? interp01(t / 0.5) : interp12((t - 0.5) / 0.5);158 };159 160 const svg = d3.select(container).append('svg')161 .attr('width', '100%')162 .style('display', 'block')163 .style('cursor', 'default');164 165 const render = () => {166 const width = container.clientWidth || 800;167 const height = Math.max(260, Math.round(width / 3));168 svg.attr('width', width).attr('height', height);169 170 const isDark = document.documentElement.getAttribute('data-theme') === 'dark';171 const strokeColor = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.12)';172 const glowColor = isDark ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.25)';173 174 // Base position (left side, bottom)175 const baseX = width * 0.15;176 const baseY = height * 0.85;177 178 // Max reach of arm (sum of all segments)179 const maxReach = armLengths.reduce((a, b) => a + b, 0); // 300px180 181 // Initialize object positions (only once) - on table surface, within reach182 if (objects[0].x === 0) {183 const tableY = baseY - 20; // Same as table surface184 185 objects[0].x = baseX + 130;186 objects[0].y = tableY - objects[0].size / 2 - 4;187 objects[0].targetX = objects[0].x;188 objects[0].targetY = objects[0].y;189 190 objects[1].x = baseX + 200;191 objects[1].y = tableY - objects[1].size / 2 - 4;192 objects[1].targetX = objects[1].x;193 objects[1].targetY = objects[1].y;194 195 objects[2].x = baseX + 270;196 objects[2].y = tableY - objects[2].size / 2 - 4;197 objects[2].targetX = objects[2].x;198 objects[2].targetY = objects[2].y;199 200 currentX = baseX + 180;201 currentY = baseY - 150;202 }203 204 // Task execution system205 const task = tasks[currentTask % tasks.length];206 taskProgress++;207 208 if (taskProgress >= task.duration) {209 taskProgress = 0;210 currentTask++;211 if (currentTask >= tasks.length) {212 currentTask = 0;213 // Reset objects to initial positions on table214 const tableY = baseY - 20;215 216 objects[0].x = baseX + 130; 217 objects[0].y = tableY - objects[0].size / 2 - 4;218 objects[0].targetX = objects[0].x; 219 objects[0].targetY = objects[0].y;220 objects[0].placed = false;221 222 objects[1].x = baseX + 200; 223 objects[1].y = tableY - objects[1].size / 2 - 4;224 objects[1].targetX = objects[1].x; 225 objects[1].targetY = objects[1].y;226 objects[1].placed = false;227 228 objects[2].x = baseX + 270; 229 objects[2].y = tableY - objects[2].size / 2 - 4;230 objects[2].targetX = objects[2].x; 231 objects[2].targetY = objects[2].y;232 objects[2].placed = false;233 234 trail.length = 0;235 }236 }237 238 const t = taskProgress / task.duration; // 0 to 1239 // Easing function for smooth movement240 const easeInOutCubic = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;241 const smoothT = easeInOutCubic(t);242 243 // Execute current task with smooth transitions244 if (task.type === 'pick') {245 const obj = objects.find(o => o.id === task.objectId);246 if (obj) {247 // Approach object smoothly - hover above it248 targetX = obj.x;249 targetY = obj.y - 20; // Hover well above object250 251 // Smooth gripper closing252 if (t > 0.6) {253 gripperOpenness = Math.max(0, 1 - (t - 0.6) / 0.3);254 if (t > 0.75 && gripperOpen) {255 gripperOpen = false;256 heldObject = obj;257 }258 } else {259 gripperOpenness = 1.0;260 }261 }262 } else if (task.type === 'move') {263 // Move to placement position (within reach, above table)264 const destX = baseX + 150 + task.x * 100; // Spread across reachable area265 const destY = baseY - 80 - task.y * 60; // Stay above table, within reach266 targetX = destX;267 targetY = destY;268 } else if (task.type === 'place') {269 // Open gripper smoothly270 if (t > 0.2) {271 gripperOpenness = Math.min(1, (t - 0.2) / 0.4);272 if (t > 0.4 && !gripperOpen) {273 gripperOpen = true;274 if (heldObject) {275 heldObject.targetX = targetX;276 heldObject.targetY = targetY + 20;277 heldObject.placed = true;278 heldObject = null;279 }280 }281 }282 } else if (task.type === 'return') {283 targetX = baseX + 180;284 targetY = baseY - 140;285 gripperOpenness = 1.0;286 } else if (task.type === 'idle') {287 targetX = baseX + 180;288 targetY = baseY - 140;289 gripperOpenness = 1.0;290 }291 292 // Smooth interpolation of current position towards target293 const lerpFactor = 0.12; // Smoothing factor294 currentX += (targetX - currentX) * lerpFactor;295 currentY += (targetY - currentY) * lerpFactor;296 297 // Solve IK using smoothed position298 const { angles, positions: joints } = solveIK(currentX, currentY, baseX, baseY);299 300 // Update trail301 const endEffector = joints[joints.length - 1];302 trail.push({ x: endEffector.x, y: endEffector.y });303 if (trail.length > trailLength) trail.shift();304 305 // Smooth object positions towards their targets306 objects.forEach(obj => {307 const objLerpFactor = 0.15;308 obj.x += (obj.targetX - obj.x) * objLerpFactor;309 obj.y += (obj.targetY - obj.y) * objLerpFactor;310 });311 312 // Update held object position (stick to gripper)313 if (heldObject) {314 heldObject.x = endEffector.x;315 heldObject.y = endEffector.y + 8;316 }317 318 // Ensure container can host tooltip319 container.style.position = container.style.position || 'relative';320 let tip = container.querySelector('.d3-tooltip');321 let tipInner;322 if (!tip) {323 tip = document.createElement('div');324 tip.className = 'd3-tooltip';325 Object.assign(tip.style, {326 position: 'absolute',327 top: '0px',328 left: '0px',329 transform: 'translate(-9999px, -9999px)',330 pointerEvents: 'none',331 padding: '10px 12px',332 borderRadius: '12px',333 fontSize: '12px',334 lineHeight: '1.35',335 border: '1px solid var(--border-color)',336 background: 'var(--surface-bg)',337 color: 'var(--text-color)',338 boxShadow: '0 8px 32px rgba(0,0,0,.28), 0 2px 8px rgba(0,0,0,.12)',339 opacity: '0',340 transition: 'opacity .12s ease',341 backdropFilter: 'saturate(1.12) blur(8px)',342 zIndex: '20'343 });344 tipInner = document.createElement('div');345 tipInner.className = 'd3-tooltip__inner';346 Object.assign(tipInner.style, {347 textAlign: 'left',348 display: 'flex',349 flexDirection: 'column',350 gap: '6px',351 minWidth: '220px'352 });353 tip.appendChild(tipInner);354 container.appendChild(tip);355 } else {356 tipInner = tip.querySelector('.d3-tooltip__inner') || tip;357 }358 359 // Draw workspace table/surface360 const workspaceGroup = svg.selectAll('g.workspace').data([0]).join('g').attr('class', 'workspace');361 362 // Table surface363 workspaceGroup.selectAll('rect.table').data([0]).join('rect')364 .attr('class', 'table')365 .attr('x', baseX + 100)366 .attr('y', baseY - 20)367 .attr('width', 200)368 .attr('height', 8)369 .attr('fill', isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)')370 .attr('stroke', isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)')371 .attr('stroke-width', 1)372 .attr('rx', 2);373 374 // Draw robot base/body375 const bodyGroup = svg.selectAll('g.robot-body').data([0]).join('g').attr('class', 'robot-body');376 377 // Base platform378 bodyGroup.selectAll('rect.base').data([0]).join('rect')379 .attr('class', 'base')380 .attr('x', baseX - 60)381 .attr('y', baseY - 10)382 .attr('width', 120)383 .attr('height', 30)384 .attr('fill', colorFor(0.1))385 .attr('stroke', isDark ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)')386 .attr('stroke-width', 2)387 .attr('rx', 4);388 389 // Vertical pillar390 bodyGroup.selectAll('rect.pillar').data([0]).join('rect')391 .attr('class', 'pillar')392 .attr('x', baseX - 15)393 .attr('y', baseY - 40)394 .attr('width', 30)395 .attr('height', 40)396 .attr('fill', colorFor(0.15))397 .attr('stroke', isDark ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)')398 .attr('stroke-width', 2)399 .attr('rx', 3);400 401 // Draw trail (lighter, behind everything)402 const trailGroup = svg.selectAll('g.trail').data([0]).join('g').attr('class', 'trail');403 const trailPath = d3.line()404 .x(d => d.x)405 .y(d => d.y)406 .curve(d3.curveCatmullRom.alpha(0.5));407 408 trailGroup.selectAll('path').data([trail]).join('path')409 .attr('d', trailPath)410 .attr('fill', 'none')411 .attr('stroke', colorFor(0.7))412 .attr('stroke-width', 1.5)413 .attr('stroke-opacity', 0.25)414 .attr('stroke-linecap', 'round');415 416 // Draw arm segments417 const armGroup = svg.selectAll('g.arm').data([0]).join('g').attr('class', 'arm');418 419 armGroup.selectAll('line.segment').data(d3.range(numSegments)).join('line')420 .attr('class', 'segment')421 .attr('x1', i => joints[i].x)422 .attr('y1', i => joints[i].y)423 .attr('x2', i => joints[i + 1].x)424 .attr('y2', i => joints[i + 1].y)425 .attr('stroke', (d, i) => colorFor(i / (numSegments - 1)))426 .attr('stroke-width', (d, i) => 8 - i * 1.5)427 .attr('stroke-linecap', 'round')428 .attr('stroke-opacity', 0.9);429 430 // Draw objects to manipulate431 const objectsGroup = svg.selectAll('g.objects').data([0]).join('g').attr('class', 'objects');432 433 objectsGroup.selectAll('.object').data(objects).join(434 enter => {435 const g = enter.append('g').attr('class', 'object');436 g.each(function(d) {437 const elem = d3.select(this);438 if (d.shape === 'square') {439 elem.append('rect')440 .attr('class', 'obj-shape')441 .attr('width', d.size)442 .attr('height', d.size)443 .attr('rx', 3);444 } else {445 elem.append('circle')446 .attr('class', 'obj-shape')447 .attr('r', d.size / 2);448 }449 });450 return g;451 },452 update => update453 )454 .attr('transform', d => `translate(${d.x}, ${d.y})`)455 .style('cursor', 'pointer')456 .each(function(d) {457 const shape = d3.select(this).select('.obj-shape');458 if (d.shape === 'square') {459 shape460 .attr('x', -d.size / 2)461 .attr('y', -d.size / 2)462 .attr('fill', colorFor(d.color))463 .attr('stroke', isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.3)')464 .attr('stroke-width', 2);465 } else {466 shape467 .attr('fill', colorFor(d.color))468 .attr('stroke', isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.3)')469 .attr('stroke-width', 2);470 }471 })472 .on('mouseenter', function(ev, d) {473 d3.select(this).select('.obj-shape')474 .transition().duration(120)475 .attr('transform', 'scale(1.1)');476 tipInner.innerHTML =477 `<div style="font-weight:800;"><strong>${d.label}</strong></div>` +478 `<div style="font-size:11px;color:var(--muted-color);margin-top:-2px;">Object ${d.shape === 'square' ? '(Cube)' : '(Sphere)'}</div>` +479 `<div style="padding-top:4px;border-top:1px solid var(--border-color);"><strong>Position</strong> X ${d.x.toFixed(0)} · Y ${d.y.toFixed(0)}</div>` +480 `<div><strong>Status</strong> ${heldObject === d ? 'Grasped' : 'On table'}</div>`;481 tip.style.opacity = '1';482 })483 .on('mousemove', (ev) => {484 const [mx, my] = d3.pointer(ev, container);485 tip.style.transform = `translate(${Math.round(mx + 10)}px, ${Math.round(my + 12)}px)`;486 })487 .on('mouseleave', function() {488 d3.select(this).select('.obj-shape')489 .transition().duration(120)490 .attr('transform', 'scale(1)');491 tip.style.opacity = '0';492 tip.style.transform = 'translate(-9999px, -9999px)';493 });494 495 // Draw gripper (pincers at end effector) with smooth animation496 const gripperGroup = svg.selectAll('g.gripper').data([0]).join('g').attr('class', 'gripper');497 const gripperAngle = angles[numSegments - 1];498 const gripperSize = 8 + gripperOpenness * 10; // 8px (closed) to 18px (open)499 500 gripperGroup.selectAll('line.gripper-jaw').data([1, -1]).join('line')501 .attr('class', 'gripper-jaw')502 .attr('x1', endEffector.x)503 .attr('y1', endEffector.y)504 .attr('x2', d => endEffector.x + Math.cos(gripperAngle + Math.PI / 2 * d) * gripperSize)505 .attr('y2', d => endEffector.y + Math.sin(gripperAngle + Math.PI / 2 * d) * gripperSize)506 .attr('stroke', colorFor(0.9))507 .attr('stroke-width', 4)508 .attr('stroke-linecap', 'round')509 .style('transition', 'all 0.1s ease');510 511 // Draw joints512 const jointGroup = svg.selectAll('g.joints').data([0]).join('g').attr('class', 'joints');513 514 jointGroup.selectAll('circle.joint').data(joints.slice(0, -1)).join('circle') // Don't draw last joint (gripper is there)515 .attr('class', 'joint')516 .attr('cx', d => d.x)517 .attr('cy', d => d.y)518 .attr('r', (d, i) => i === 0 ? 12 : (i === joints.length - 1 ? 10 : 8))519 .attr('fill', (d, i) => i === 0 ? colorFor(0) : (i === joints.length - 1 ? colorFor(1) : colorFor(i / joints.length)))520 .attr('stroke', isDark ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.2)')521 .attr('stroke-width', 2)522 .style('cursor', 'pointer')523 .on('mouseenter', function(ev, d, i) {524 const idx = joints.indexOf(d);525 d3.select(this)526 .raise()527 .style('filter', `drop-shadow(0 0 12px ${glowColor})`)528 .transition().duration(120).ease(d3.easeCubicOut)529 .attr('r', (idx === 0 ? 12 : (idx === joints.length - 1 ? 10 : 8)) * 1.3)530 .attr('stroke', isDark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.85)')531 .attr('stroke-width', 3);532 533 const jointName = idx === 0 ? 'Base Joint' : `Joint ${idx}`;534 const angle = idx > 0 ? angles[idx - 1] * (180 / Math.PI) : 0;535 536 tipInner.innerHTML =537 `<div style="font-weight:800;letter-spacing:.1px;"><strong>${jointName}</strong></div>` +538 `<div style="font-size:11px;color:var(--muted-color);margin-top:-4px;margin-bottom:2px;letter-spacing:.1px;">Robot Arm Joint</div>` +539 `<div style="padding-top:6px;border-top:1px solid var(--border-color);"><strong>Position</strong> X ${d.x.toFixed(1)} · <strong>Y</strong> ${d.y.toFixed(1)}</div>` +540 (idx > 0 ? `<div><strong>Angle</strong> ${angle.toFixed(1)}°</div>` : '') +541 `<div><strong>Current Task</strong> ${task.type}</div>`;542 tip.style.opacity = '1';543 })544 .on('mousemove', (ev) => {545 const [mx, my] = d3.pointer(ev, container);546 tip.style.transform = `translate(${Math.round(mx + 10)}px, ${Math.round(my + 12)}px)`;547 })548 .on('mouseleave', function(ev, d) {549 const idx = joints.indexOf(d);550 tip.style.opacity = '0';551 tip.style.transform = 'translate(-9999px, -9999px)';552 d3.select(this)553 .style('filter', null)554 .transition().duration(120).ease(d3.easeCubicOut)555 .attr('r', idx === 0 ? 12 : (idx === joints.length - 1 ? 10 : 8))556 .attr('stroke', isDark ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.2)')557 .attr('stroke-width', 2);558 });559 560 // Draw task status label561 const statusGroup = svg.selectAll('g.status').data([0]).join('g').attr('class', 'status');562 563 const taskText = task.type === 'pick' ? `Picking ${objects.find(o => o.id === task.objectId)?.label}` :564 task.type === 'move' ? 'Moving object' :565 task.type === 'place' ? 'Placing object' :566 task.type === 'return' ? 'Returning to home' : 'Idle';567 568 statusGroup.selectAll('text.task-label').data([taskText]).join('text')569 .attr('class', 'task-label')570 .attr('x', width - 20)571 .attr('y', 30)572 .attr('text-anchor', 'end')573 .attr('font-size', '14px')574 .attr('font-weight', '600')575 .attr('fill', colorFor(0.5))576 .attr('opacity', 0.8)577 .text(d => d);578 579 // Draw progress bar580 statusGroup.selectAll('rect.progress-bg').data([0]).join('rect')581 .attr('class', 'progress-bg')582 .attr('x', width - 200)583 .attr('y', 40)584 .attr('width', 180)585 .attr('height', 6)586 .attr('fill', isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)')587 .attr('rx', 3);588 589 statusGroup.selectAll('rect.progress-fill').data([0]).join('rect')590 .attr('class', 'progress-fill')591 .attr('x', width - 200)592 .attr('y', 40)593 .attr('width', 180 * t)594 .attr('height', 6)595 .attr('fill', colorFor(0.5))596 .attr('rx', 3);597 };598 599 // Animation loop600 let animationFrame;601 const animate = () => {602 render();603 animationFrame = requestAnimationFrame(animate);604 };605 606 // Resize handling607 if (window.ResizeObserver) {608 const ro = new ResizeObserver(() => {609 // render() is already called in animate loop610 });611 ro.observe(container);612 } else {613 window.addEventListener('resize', () => {614 // render() is already called in animate loop615 });616 }617 618 // Start animation619 animate();620 621 // Cleanup on unmount622 const cleanup = () => {623 if (animationFrame) cancelAnimationFrame(animationFrame);624 };625 if (container.dataset) container.dataset.cleanup = cleanup;626 };627 628 if (document.readyState === 'loading') {629 document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });630 } else { ensureD3(bootstrap); }631 })();632</script>633 634 