| | <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> |
| |
|
| |
|