tfrere's picture
tfrere HF Staff
update
759d176
raw
history blame
25.8 kB
<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>