Spaces:
Running
Running
| <div class="d3-robot-arm" style="width:100%;margin:10px 0;aspect-ratio:3/1;min-height:260px;"></div> | |
| <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-robot-arm')) || document.querySelector('.d3-robot-arm'); | |
| if (!container) return; | |
| if (container.dataset) { | |
| if (container.dataset.mounted === 'true') return; | |
| container.dataset.mounted = 'true'; | |
| } | |
| // Robot arm parameters | |
| const armLengths = [120, 100, 80]; // 3 segments | |
| const numSegments = armLengths.length; | |
| // Trail for end effector | |
| const trailLength = 80; | |
| const trail = []; | |
| // Animation state | |
| let targetX = 0, targetY = 0; | |
| let currentX = 0, currentY = 0; // Smooth interpolated position | |
| let time = 0; | |
| let prevPositions = null; | |
| // Task system | |
| let currentTask = 0; | |
| let taskProgress = 0; | |
| let gripperOpen = true; | |
| let gripperOpenness = 1.0; // 0 = closed, 1 = open (for smooth animation) | |
| let heldObject = null; | |
| // Objects to manipulate with initial and target positions | |
| const objects = [ | |
| { id: 1, x: 0, y: 0, targetX: 0, targetY: 0, size: 25, color: 0.2, label: 'Cube A', shape: 'square', placed: false }, | |
| { id: 2, x: 0, y: 0, targetX: 0, targetY: 0, size: 20, color: 0.5, label: 'Ball B', shape: 'circle', placed: false }, | |
| { id: 3, x: 0, y: 0, targetX: 0, targetY: 0, size: 22, color: 0.8, label: 'Cube C', shape: 'square', placed: false } | |
| ]; | |
| // Task sequences optimized for smooth reach | |
| const tasks = [ | |
| { type: 'pick', objectId: 1, duration: 100 }, | |
| { type: 'move', x: 0.3, y: 0.5, duration: 90 }, | |
| { type: 'place', duration: 60 }, | |
| { type: 'return', duration: 80 }, | |
| { type: 'pick', objectId: 2, duration: 100 }, | |
| { type: 'move', x: 0.5, y: 0.6, duration: 90 }, | |
| { type: 'place', duration: 60 }, | |
| { type: 'return', duration: 80 }, | |
| { type: 'pick', objectId: 3, duration: 100 }, | |
| { type: 'move', x: 0.7, y: 0.5, duration: 90 }, | |
| { type: 'place', duration: 60 }, | |
| { type: 'idle', duration: 100 } | |
| ]; | |
| // FABRIK Inverse Kinematics solver (Forward And Backward Reaching) | |
| const solveIK = (targetX, targetY, baseX, baseY) => { | |
| // Initialize joint positions (use previous if available, otherwise extend horizontally) | |
| let positions; | |
| if (prevPositions && prevPositions.length === numSegments + 1) { | |
| // Use previous positions as starting point for smooth animation | |
| positions = prevPositions.map(p => ({ x: p.x, y: p.y })); | |
| positions[0] = { x: baseX, y: baseY }; // Always fix base | |
| } else { | |
| // Initial position: extend arm horizontally to the right | |
| positions = [{ x: baseX, y: baseY }]; | |
| let x = baseX, y = baseY; | |
| for (let i = 0; i < numSegments; i++) { | |
| x += armLengths[i]; | |
| positions.push({ x, y }); | |
| } | |
| } | |
| // FABRIK iterations | |
| const maxIterations = 10; | |
| const tolerance = 0.1; | |
| for (let iter = 0; iter < maxIterations; iter++) { | |
| // Forward reaching: start from end effector | |
| positions[numSegments].x = targetX; | |
| positions[numSegments].y = targetY; | |
| for (let i = numSegments - 1; i >= 0; i--) { | |
| const dx = positions[i].x - positions[i + 1].x; | |
| const dy = positions[i].y - positions[i + 1].y; | |
| const dist = Math.sqrt(dx * dx + dy * dy); | |
| if (dist > 0) { | |
| const lambda = armLengths[i] / dist; | |
| positions[i].x = positions[i + 1].x + dx * lambda; | |
| positions[i].y = positions[i + 1].y + dy * lambda; | |
| } | |
| } | |
| // Backward reaching: start from base | |
| positions[0].x = baseX; | |
| positions[0].y = baseY; | |
| for (let i = 0; i < numSegments; i++) { | |
| const dx = positions[i + 1].x - positions[i].x; | |
| const dy = positions[i + 1].y - positions[i].y; | |
| const dist = Math.sqrt(dx * dx + dy * dy); | |
| if (dist > 0) { | |
| const lambda = armLengths[i] / dist; | |
| positions[i + 1].x = positions[i].x + dx * lambda; | |
| positions[i + 1].y = positions[i].y + dy * lambda; | |
| } | |
| } | |
| // Check convergence | |
| const endDx = positions[numSegments].x - targetX; | |
| const endDy = positions[numSegments].y - targetY; | |
| const endDist = Math.sqrt(endDx * endDx + endDy * endDy); | |
| if (endDist < tolerance) break; | |
| } | |
| // Calculate angles from positions | |
| const angles = []; | |
| for (let i = 0; i < numSegments; i++) { | |
| const dx = positions[i + 1].x - positions[i].x; | |
| const dy = positions[i + 1].y - positions[i].y; | |
| angles.push(Math.atan2(dy, dx)); | |
| } | |
| // Save positions for next iteration | |
| prevPositions = positions; | |
| return { angles, positions }; | |
| }; | |
| // Colors | |
| const c0 = d3.rgb(78, 165, 183); | |
| const c1 = d3.rgb(206, 192, 250); | |
| const c2 = d3.rgb(232, 137, 171); | |
| const interp01 = d3.interpolateRgb(c0, c1); | |
| const interp12 = d3.interpolateRgb(c1, c2); | |
| const colorFor = (v) => { | |
| const t = Math.max(0, Math.min(1, v)); | |
| return t <= 0.5 ? interp01(t / 0.5) : interp12((t - 0.5) / 0.5); | |
| }; | |
| const svg = d3.select(container).append('svg') | |
| .attr('width', '100%') | |
| .style('display', 'block') | |
| .style('cursor', 'default'); | |
| const render = () => { | |
| const width = container.clientWidth || 800; | |
| const height = Math.max(260, Math.round(width / 3)); | |
| svg.attr('width', width).attr('height', height); | |
| const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; | |
| const strokeColor = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.12)'; | |
| const glowColor = isDark ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.25)'; | |
| // Base position (left side, bottom) | |
| const baseX = width * 0.15; | |
| const baseY = height * 0.85; | |
| // Max reach of arm (sum of all segments) | |
| const maxReach = armLengths.reduce((a, b) => a + b, 0); // 300px | |
| // Initialize object positions (only once) - on table surface, within reach | |
| if (objects[0].x === 0) { | |
| const tableY = baseY - 20; // Same as table surface | |
| objects[0].x = baseX + 130; | |
| objects[0].y = tableY - objects[0].size / 2 - 4; | |
| objects[0].targetX = objects[0].x; | |
| objects[0].targetY = objects[0].y; | |
| objects[1].x = baseX + 200; | |
| objects[1].y = tableY - objects[1].size / 2 - 4; | |
| objects[1].targetX = objects[1].x; | |
| objects[1].targetY = objects[1].y; | |
| objects[2].x = baseX + 270; | |
| objects[2].y = tableY - objects[2].size / 2 - 4; | |
| objects[2].targetX = objects[2].x; | |
| objects[2].targetY = objects[2].y; | |
| currentX = baseX + 180; | |
| currentY = baseY - 150; | |
| } | |
| // Task execution system | |
| const task = tasks[currentTask % tasks.length]; | |
| taskProgress++; | |
| if (taskProgress >= task.duration) { | |
| taskProgress = 0; | |
| currentTask++; | |
| if (currentTask >= tasks.length) { | |
| currentTask = 0; | |
| // Reset objects to initial positions on table | |
| const tableY = baseY - 20; | |
| objects[0].x = baseX + 130; | |
| objects[0].y = tableY - objects[0].size / 2 - 4; | |
| objects[0].targetX = objects[0].x; | |
| objects[0].targetY = objects[0].y; | |
| objects[0].placed = false; | |
| objects[1].x = baseX + 200; | |
| objects[1].y = tableY - objects[1].size / 2 - 4; | |
| objects[1].targetX = objects[1].x; | |
| objects[1].targetY = objects[1].y; | |
| objects[1].placed = false; | |
| objects[2].x = baseX + 270; | |
| objects[2].y = tableY - objects[2].size / 2 - 4; | |
| objects[2].targetX = objects[2].x; | |
| objects[2].targetY = objects[2].y; | |
| objects[2].placed = false; | |
| trail.length = 0; | |
| } | |
| } | |
| const t = taskProgress / task.duration; // 0 to 1 | |
| // Easing function for smooth movement | |
| const easeInOutCubic = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; | |
| const smoothT = easeInOutCubic(t); | |
| // Execute current task with smooth transitions | |
| if (task.type === 'pick') { | |
| const obj = objects.find(o => o.id === task.objectId); | |
| if (obj) { | |
| // Approach object smoothly - hover above it | |
| targetX = obj.x; | |
| targetY = obj.y - 20; // Hover well above object | |
| // Smooth gripper closing | |
| if (t > 0.6) { | |
| gripperOpenness = Math.max(0, 1 - (t - 0.6) / 0.3); | |
| if (t > 0.75 && gripperOpen) { | |
| gripperOpen = false; | |
| heldObject = obj; | |
| } | |
| } else { | |
| gripperOpenness = 1.0; | |
| } | |
| } | |
| } else if (task.type === 'move') { | |
| // Move to placement position (within reach, above table) | |
| const destX = baseX + 150 + task.x * 100; // Spread across reachable area | |
| const destY = baseY - 80 - task.y * 60; // Stay above table, within reach | |
| targetX = destX; | |
| targetY = destY; | |
| } else if (task.type === 'place') { | |
| // Open gripper smoothly | |
| if (t > 0.2) { | |
| gripperOpenness = Math.min(1, (t - 0.2) / 0.4); | |
| if (t > 0.4 && !gripperOpen) { | |
| gripperOpen = true; | |
| if (heldObject) { | |
| heldObject.targetX = targetX; | |
| heldObject.targetY = targetY + 20; | |
| heldObject.placed = true; | |
| heldObject = null; | |
| } | |
| } | |
| } | |
| } else if (task.type === 'return') { | |
| targetX = baseX + 180; | |
| targetY = baseY - 140; | |
| gripperOpenness = 1.0; | |
| } else if (task.type === 'idle') { | |
| targetX = baseX + 180; | |
| targetY = baseY - 140; | |
| gripperOpenness = 1.0; | |
| } | |
| // Smooth interpolation of current position towards target | |
| const lerpFactor = 0.12; // Smoothing factor | |
| currentX += (targetX - currentX) * lerpFactor; | |
| currentY += (targetY - currentY) * lerpFactor; | |
| // Solve IK using smoothed position | |
| const { angles, positions: joints } = solveIK(currentX, currentY, baseX, baseY); | |
| // Update trail | |
| const endEffector = joints[joints.length - 1]; | |
| trail.push({ x: endEffector.x, y: endEffector.y }); | |
| if (trail.length > trailLength) trail.shift(); | |
| // Smooth object positions towards their targets | |
| objects.forEach(obj => { | |
| const objLerpFactor = 0.15; | |
| obj.x += (obj.targetX - obj.x) * objLerpFactor; | |
| obj.y += (obj.targetY - obj.y) * objLerpFactor; | |
| }); | |
| // Update held object position (stick to gripper) | |
| if (heldObject) { | |
| heldObject.x = endEffector.x; | |
| heldObject.y = endEffector.y + 8; | |
| } | |
| // Ensure container can host tooltip | |
| 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: '10px 12px', | |
| borderRadius: '12px', | |
| fontSize: '12px', | |
| lineHeight: '1.35', | |
| border: '1px solid var(--border-color)', | |
| background: 'var(--surface-bg)', | |
| color: 'var(--text-color)', | |
| boxShadow: '0 8px 32px rgba(0,0,0,.28), 0 2px 8px rgba(0,0,0,.12)', | |
| opacity: '0', | |
| transition: 'opacity .12s ease', | |
| backdropFilter: 'saturate(1.12) blur(8px)', | |
| zIndex: '20' | |
| }); | |
| tipInner = document.createElement('div'); | |
| tipInner.className = 'd3-tooltip__inner'; | |
| Object.assign(tipInner.style, { | |
| textAlign: 'left', | |
| display: 'flex', | |
| flexDirection: 'column', | |
| gap: '6px', | |
| minWidth: '220px' | |
| }); | |
| tip.appendChild(tipInner); | |
| container.appendChild(tip); | |
| } else { | |
| tipInner = tip.querySelector('.d3-tooltip__inner') || tip; | |
| } | |
| // Draw workspace table/surface | |
| const workspaceGroup = svg.selectAll('g.workspace').data([0]).join('g').attr('class', 'workspace'); | |
| // Table surface | |
| workspaceGroup.selectAll('rect.table').data([0]).join('rect') | |
| .attr('class', 'table') | |
| .attr('x', baseX + 100) | |
| .attr('y', baseY - 20) | |
| .attr('width', 200) | |
| .attr('height', 8) | |
| .attr('fill', isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)') | |
| .attr('stroke', isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)') | |
| .attr('stroke-width', 1) | |
| .attr('rx', 2); | |
| // Draw robot base/body | |
| const bodyGroup = svg.selectAll('g.robot-body').data([0]).join('g').attr('class', 'robot-body'); | |
| // Base platform | |
| bodyGroup.selectAll('rect.base').data([0]).join('rect') | |
| .attr('class', 'base') | |
| .attr('x', baseX - 60) | |
| .attr('y', baseY - 10) | |
| .attr('width', 120) | |
| .attr('height', 30) | |
| .attr('fill', colorFor(0.1)) | |
| .attr('stroke', isDark ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)') | |
| .attr('stroke-width', 2) | |
| .attr('rx', 4); | |
| // Vertical pillar | |
| bodyGroup.selectAll('rect.pillar').data([0]).join('rect') | |
| .attr('class', 'pillar') | |
| .attr('x', baseX - 15) | |
| .attr('y', baseY - 40) | |
| .attr('width', 30) | |
| .attr('height', 40) | |
| .attr('fill', colorFor(0.15)) | |
| .attr('stroke', isDark ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)') | |
| .attr('stroke-width', 2) | |
| .attr('rx', 3); | |
| // Draw trail (lighter, behind everything) | |
| const trailGroup = svg.selectAll('g.trail').data([0]).join('g').attr('class', 'trail'); | |
| const trailPath = d3.line() | |
| .x(d => d.x) | |
| .y(d => d.y) | |
| .curve(d3.curveCatmullRom.alpha(0.5)); | |
| trailGroup.selectAll('path').data([trail]).join('path') | |
| .attr('d', trailPath) | |
| .attr('fill', 'none') | |
| .attr('stroke', colorFor(0.7)) | |
| .attr('stroke-width', 1.5) | |
| .attr('stroke-opacity', 0.25) | |
| .attr('stroke-linecap', 'round'); | |
| // Draw arm segments | |
| const armGroup = svg.selectAll('g.arm').data([0]).join('g').attr('class', 'arm'); | |
| armGroup.selectAll('line.segment').data(d3.range(numSegments)).join('line') | |
| .attr('class', 'segment') | |
| .attr('x1', i => joints[i].x) | |
| .attr('y1', i => joints[i].y) | |
| .attr('x2', i => joints[i + 1].x) | |
| .attr('y2', i => joints[i + 1].y) | |
| .attr('stroke', (d, i) => colorFor(i / (numSegments - 1))) | |
| .attr('stroke-width', (d, i) => 8 - i * 1.5) | |
| .attr('stroke-linecap', 'round') | |
| .attr('stroke-opacity', 0.9); | |
| // Draw objects to manipulate | |
| const objectsGroup = svg.selectAll('g.objects').data([0]).join('g').attr('class', 'objects'); | |
| objectsGroup.selectAll('.object').data(objects).join( | |
| enter => { | |
| const g = enter.append('g').attr('class', 'object'); | |
| g.each(function(d) { | |
| const elem = d3.select(this); | |
| if (d.shape === 'square') { | |
| elem.append('rect') | |
| .attr('class', 'obj-shape') | |
| .attr('width', d.size) | |
| .attr('height', d.size) | |
| .attr('rx', 3); | |
| } else { | |
| elem.append('circle') | |
| .attr('class', 'obj-shape') | |
| .attr('r', d.size / 2); | |
| } | |
| }); | |
| return g; | |
| }, | |
| update => update | |
| ) | |
| .attr('transform', d => `translate(${d.x}, ${d.y})`) | |
| .style('cursor', 'pointer') | |
| .each(function(d) { | |
| const shape = d3.select(this).select('.obj-shape'); | |
| if (d.shape === 'square') { | |
| shape | |
| .attr('x', -d.size / 2) | |
| .attr('y', -d.size / 2) | |
| .attr('fill', colorFor(d.color)) | |
| .attr('stroke', isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.3)') | |
| .attr('stroke-width', 2); | |
| } else { | |
| shape | |
| .attr('fill', colorFor(d.color)) | |
| .attr('stroke', isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.3)') | |
| .attr('stroke-width', 2); | |
| } | |
| }) | |
| .on('mouseenter', function(ev, d) { | |
| d3.select(this).select('.obj-shape') | |
| .transition().duration(120) | |
| .attr('transform', 'scale(1.1)'); | |
| tipInner.innerHTML = | |
| `<div style="font-weight:800;"><strong>${d.label}</strong></div>` + | |
| `<div style="font-size:11px;color:var(--muted-color);margin-top:-2px;">Object ${d.shape === 'square' ? '(Cube)' : '(Sphere)'}</div>` + | |
| `<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>` + | |
| `<div><strong>Status</strong> ${heldObject === d ? 'Grasped' : 'On table'}</div>`; | |
| tip.style.opacity = '1'; | |
| }) | |
| .on('mousemove', (ev) => { | |
| const [mx, my] = d3.pointer(ev, container); | |
| tip.style.transform = `translate(${Math.round(mx + 10)}px, ${Math.round(my + 12)}px)`; | |
| }) | |
| .on('mouseleave', function() { | |
| d3.select(this).select('.obj-shape') | |
| .transition().duration(120) | |
| .attr('transform', 'scale(1)'); | |
| tip.style.opacity = '0'; | |
| tip.style.transform = 'translate(-9999px, -9999px)'; | |
| }); | |
| // Draw gripper (pincers at end effector) with smooth animation | |
| const gripperGroup = svg.selectAll('g.gripper').data([0]).join('g').attr('class', 'gripper'); | |
| const gripperAngle = angles[numSegments - 1]; | |
| const gripperSize = 8 + gripperOpenness * 10; // 8px (closed) to 18px (open) | |
| gripperGroup.selectAll('line.gripper-jaw').data([1, -1]).join('line') | |
| .attr('class', 'gripper-jaw') | |
| .attr('x1', endEffector.x) | |
| .attr('y1', endEffector.y) | |
| .attr('x2', d => endEffector.x + Math.cos(gripperAngle + Math.PI / 2 * d) * gripperSize) | |
| .attr('y2', d => endEffector.y + Math.sin(gripperAngle + Math.PI / 2 * d) * gripperSize) | |
| .attr('stroke', colorFor(0.9)) | |
| .attr('stroke-width', 4) | |
| .attr('stroke-linecap', 'round') | |
| .style('transition', 'all 0.1s ease'); | |
| // Draw joints | |
| const jointGroup = svg.selectAll('g.joints').data([0]).join('g').attr('class', 'joints'); | |
| jointGroup.selectAll('circle.joint').data(joints.slice(0, -1)).join('circle') // Don't draw last joint (gripper is there) | |
| .attr('class', 'joint') | |
| .attr('cx', d => d.x) | |
| .attr('cy', d => d.y) | |
| .attr('r', (d, i) => i === 0 ? 12 : (i === joints.length - 1 ? 10 : 8)) | |
| .attr('fill', (d, i) => i === 0 ? colorFor(0) : (i === joints.length - 1 ? colorFor(1) : colorFor(i / joints.length))) | |
| .attr('stroke', isDark ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.2)') | |
| .attr('stroke-width', 2) | |
| .style('cursor', 'pointer') | |
| .on('mouseenter', function(ev, d, i) { | |
| const idx = joints.indexOf(d); | |
| d3.select(this) | |
| .raise() | |
| .style('filter', `drop-shadow(0 0 12px ${glowColor})`) | |
| .transition().duration(120).ease(d3.easeCubicOut) | |
| .attr('r', (idx === 0 ? 12 : (idx === joints.length - 1 ? 10 : 8)) * 1.3) | |
| .attr('stroke', isDark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.85)') | |
| .attr('stroke-width', 3); | |
| const jointName = idx === 0 ? 'Base Joint' : `Joint ${idx}`; | |
| const angle = idx > 0 ? angles[idx - 1] * (180 / Math.PI) : 0; | |
| tipInner.innerHTML = | |
| `<div style="font-weight:800;letter-spacing:.1px;"><strong>${jointName}</strong></div>` + | |
| `<div style="font-size:11px;color:var(--muted-color);margin-top:-4px;margin-bottom:2px;letter-spacing:.1px;">Robot Arm Joint</div>` + | |
| `<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>` + | |
| (idx > 0 ? `<div><strong>Angle</strong> ${angle.toFixed(1)}°</div>` : '') + | |
| `<div><strong>Current Task</strong> ${task.type}</div>`; | |
| tip.style.opacity = '1'; | |
| }) | |
| .on('mousemove', (ev) => { | |
| const [mx, my] = d3.pointer(ev, container); | |
| tip.style.transform = `translate(${Math.round(mx + 10)}px, ${Math.round(my + 12)}px)`; | |
| }) | |
| .on('mouseleave', function(ev, d) { | |
| const idx = joints.indexOf(d); | |
| tip.style.opacity = '0'; | |
| tip.style.transform = 'translate(-9999px, -9999px)'; | |
| d3.select(this) | |
| .style('filter', null) | |
| .transition().duration(120).ease(d3.easeCubicOut) | |
| .attr('r', idx === 0 ? 12 : (idx === joints.length - 1 ? 10 : 8)) | |
| .attr('stroke', isDark ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.2)') | |
| .attr('stroke-width', 2); | |
| }); | |
| // Draw task status label | |
| const statusGroup = svg.selectAll('g.status').data([0]).join('g').attr('class', 'status'); | |
| const taskText = task.type === 'pick' ? `Picking ${objects.find(o => o.id === task.objectId)?.label}` : | |
| task.type === 'move' ? 'Moving object' : | |
| task.type === 'place' ? 'Placing object' : | |
| task.type === 'return' ? 'Returning to home' : 'Idle'; | |
| statusGroup.selectAll('text.task-label').data([taskText]).join('text') | |
| .attr('class', 'task-label') | |
| .attr('x', width - 20) | |
| .attr('y', 30) | |
| .attr('text-anchor', 'end') | |
| .attr('font-size', '14px') | |
| .attr('font-weight', '600') | |
| .attr('fill', colorFor(0.5)) | |
| .attr('opacity', 0.8) | |
| .text(d => d); | |
| // Draw progress bar | |
| statusGroup.selectAll('rect.progress-bg').data([0]).join('rect') | |
| .attr('class', 'progress-bg') | |
| .attr('x', width - 200) | |
| .attr('y', 40) | |
| .attr('width', 180) | |
| .attr('height', 6) | |
| .attr('fill', isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)') | |
| .attr('rx', 3); | |
| statusGroup.selectAll('rect.progress-fill').data([0]).join('rect') | |
| .attr('class', 'progress-fill') | |
| .attr('x', width - 200) | |
| .attr('y', 40) | |
| .attr('width', 180 * t) | |
| .attr('height', 6) | |
| .attr('fill', colorFor(0.5)) | |
| .attr('rx', 3); | |
| }; | |
| // Animation loop | |
| let animationFrame; | |
| const animate = () => { | |
| render(); | |
| animationFrame = requestAnimationFrame(animate); | |
| }; | |
| // Resize handling | |
| if (window.ResizeObserver) { | |
| const ro = new ResizeObserver(() => { | |
| // render() is already called in animate loop | |
| }); | |
| ro.observe(container); | |
| } else { | |
| window.addEventListener('resize', () => { | |
| // render() is already called in animate loop | |
| }); | |
| } | |
| // Start animation | |
| animate(); | |
| // Cleanup on unmount | |
| const cleanup = () => { | |
| if (animationFrame) cancelAnimationFrame(animationFrame); | |
| }; | |
| if (container.dataset) container.dataset.cleanup = cleanup; | |
| }; | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); | |
| } else { ensureD3(bootstrap); } | |
| })(); | |
| </script> | |