CoolFace
Apppublic

lerobot/robot-learning-tutorial

sourceHugging Faceupdated 1y agoView on Hugging Face
508likes
banner2.html634 linesDownload Raw Back to embeds
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