|
|
<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'; |
|
|
} |
|
|
|
|
|
|
|
|
const armLengths = [120, 100, 80]; |
|
|
const numSegments = armLengths.length; |
|
|
|
|
|
|
|
|
const trailLength = 80; |
|
|
const trail = []; |
|
|
|
|
|
|
|
|
let targetX = 0, targetY = 0; |
|
|
let currentX = 0, currentY = 0; |
|
|
let time = 0; |
|
|
let prevPositions = null; |
|
|
|
|
|
|
|
|
let currentTask = 0; |
|
|
let taskProgress = 0; |
|
|
let gripperOpen = true; |
|
|
let gripperOpenness = 1.0; |
|
|
let heldObject = null; |
|
|
|
|
|
|
|
|
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 } |
|
|
]; |
|
|
|
|
|
|
|
|
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 } |
|
|
]; |
|
|
|
|
|
|
|
|
const solveIK = (targetX, targetY, baseX, baseY) => { |
|
|
|
|
|
let positions; |
|
|
|
|
|
if (prevPositions && prevPositions.length === numSegments + 1) { |
|
|
|
|
|
positions = prevPositions.map(p => ({ x: p.x, y: p.y })); |
|
|
positions[0] = { x: baseX, y: baseY }; |
|
|
} else { |
|
|
|
|
|
positions = [{ x: baseX, y: baseY }]; |
|
|
let x = baseX, y = baseY; |
|
|
for (let i = 0; i < numSegments; i++) { |
|
|
x += armLengths[i]; |
|
|
positions.push({ x, y }); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const maxIterations = 10; |
|
|
const tolerance = 0.1; |
|
|
|
|
|
for (let iter = 0; iter < maxIterations; iter++) { |
|
|
|
|
|
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; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const endDx = positions[numSegments].x - targetX; |
|
|
const endDy = positions[numSegments].y - targetY; |
|
|
const endDist = Math.sqrt(endDx * endDx + endDy * endDy); |
|
|
|
|
|
if (endDist < tolerance) break; |
|
|
} |
|
|
|
|
|
|
|
|
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)); |
|
|
} |
|
|
|
|
|
|
|
|
prevPositions = positions; |
|
|
|
|
|
return { angles, positions }; |
|
|
}; |
|
|
|
|
|
|
|
|
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)'; |
|
|
|
|
|
|
|
|
const baseX = width * 0.15; |
|
|
const baseY = height * 0.85; |
|
|
|
|
|
|
|
|
const maxReach = armLengths.reduce((a, b) => a + b, 0); |
|
|
|
|
|
|
|
|
if (objects[0].x === 0) { |
|
|
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[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; |
|
|
} |
|
|
|
|
|
|
|
|
const task = tasks[currentTask % tasks.length]; |
|
|
taskProgress++; |
|
|
|
|
|
if (taskProgress >= task.duration) { |
|
|
taskProgress = 0; |
|
|
currentTask++; |
|
|
if (currentTask >= tasks.length) { |
|
|
currentTask = 0; |
|
|
|
|
|
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; |
|
|
|
|
|
const easeInOutCubic = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; |
|
|
const smoothT = easeInOutCubic(t); |
|
|
|
|
|
|
|
|
if (task.type === 'pick') { |
|
|
const obj = objects.find(o => o.id === task.objectId); |
|
|
if (obj) { |
|
|
|
|
|
targetX = obj.x; |
|
|
targetY = obj.y - 20; |
|
|
|
|
|
|
|
|
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') { |
|
|
|
|
|
const destX = baseX + 150 + task.x * 100; |
|
|
const destY = baseY - 80 - task.y * 60; |
|
|
targetX = destX; |
|
|
targetY = destY; |
|
|
} else if (task.type === 'place') { |
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
const lerpFactor = 0.12; |
|
|
currentX += (targetX - currentX) * lerpFactor; |
|
|
currentY += (targetY - currentY) * lerpFactor; |
|
|
|
|
|
|
|
|
const { angles, positions: joints } = solveIK(currentX, currentY, baseX, baseY); |
|
|
|
|
|
|
|
|
const endEffector = joints[joints.length - 1]; |
|
|
trail.push({ x: endEffector.x, y: endEffector.y }); |
|
|
if (trail.length > trailLength) trail.shift(); |
|
|
|
|
|
|
|
|
objects.forEach(obj => { |
|
|
const objLerpFactor = 0.15; |
|
|
obj.x += (obj.targetX - obj.x) * objLerpFactor; |
|
|
obj.y += (obj.targetY - obj.y) * objLerpFactor; |
|
|
}); |
|
|
|
|
|
|
|
|
if (heldObject) { |
|
|
heldObject.x = endEffector.x; |
|
|
heldObject.y = endEffector.y + 8; |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
const workspaceGroup = svg.selectAll('g.workspace').data([0]).join('g').attr('class', 'workspace'); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
const bodyGroup = svg.selectAll('g.robot-body').data([0]).join('g').attr('class', 'robot-body'); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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'); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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)'; |
|
|
}); |
|
|
|
|
|
|
|
|
const gripperGroup = svg.selectAll('g.gripper').data([0]).join('g').attr('class', 'gripper'); |
|
|
const gripperAngle = angles[numSegments - 1]; |
|
|
const gripperSize = 8 + gripperOpenness * 10; |
|
|
|
|
|
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'); |
|
|
|
|
|
|
|
|
const jointGroup = svg.selectAll('g.joints').data([0]).join('g').attr('class', 'joints'); |
|
|
|
|
|
jointGroup.selectAll('circle.joint').data(joints.slice(0, -1)).join('circle') |
|
|
.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); |
|
|
}); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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); |
|
|
}; |
|
|
|
|
|
|
|
|
let animationFrame; |
|
|
const animate = () => { |
|
|
render(); |
|
|
animationFrame = requestAnimationFrame(animate); |
|
|
}; |
|
|
|
|
|
|
|
|
if (window.ResizeObserver) { |
|
|
const ro = new ResizeObserver(() => { |
|
|
|
|
|
}); |
|
|
ro.observe(container); |
|
|
} else { |
|
|
window.addEventListener('resize', () => { |
|
|
|
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
animate(); |
|
|
|
|
|
|
|
|
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> |
|
|
|
|
|
|