效果图

核心代码
const svg = document.querySelector('svg');
const ellipseGroup = document.getElementById('ellipseGroup');
const ellipse = document.getElementById('ellipse');
const nodesGroup = document.getElementById('nodesGroup');
const addNodeBtn = document.getElementById('addNode');
const resetBtn = document.getElementById('resetNodes');
const cxInput = document.getElementById('cx');
const cyInput = document.getElementById('cy');
const rxInput = document.getElementById('rx');
const ryInput = document.getElementById('ry');
const rotateInput = document.getElementById('rotate');
let ellipseCX = parseInt(cxInput.value);
let ellipseCY = parseInt(cyInput.value);
let ellipseRX = parseInt(rxInput.value);
let ellipseRY = parseInt(ryInput.value);
let ellipseRotate = parseInt(rotateInput.value);
let nodes = [];
let draggingNode = null;
let initialAngle = 0;
let startAngle = 0;
ellipseGroup.setAttribute('transform', `rotate(${ellipseRotate} ${ellipseCX} ${ellipseCY})`);
cxInput.addEventListener('input', updateEllipseParams);
cyInput.addEventListener('input', updateEllipseParams);
rxInput.addEventListener('input', updateEllipseParams);
ryInput.addEventListener('input', updateEllipseParams);
rotateInput.addEventListener('input', updateEllipseParams);
document.querySelectorAll('.node').forEach(node => {
nodes.push(node);
setupDraggable(node);
});
addNodeBtn.addEventListener('click', () => {
const count = nodes.length + 1;
const currentYear = new Date().getFullYear() + count - 1;
const nodeGroup = document.createElementNS("http://www.w3.org/2000/svg", "g");
nodeGroup.setAttribute("id", `node-group-${count}`);
const node = document.createElementNS("http://www.w3.org/2000/svg", "circle");
node.setAttribute("class", "node");
node.setAttribute("r", 7);
node.setAttribute("fill", "#3f6ffc");
node.setAttribute("id", `node-${count}`);
nodeGroup.appendChild(node);
const nodeText = document.createElementNS("http://www.w3.org/2000/svg", "text");
nodeText.setAttribute("class", "node-text");
nodeText.textContent = currentYear;
nodeGroup.appendChild(nodeText);
nodesGroup.appendChild(nodeGroup);
nodes.push(node);
setupDraggable(node);
updateNodePositions();
});
resetBtn.addEventListener('click', () => {
while (nodes.length > 1) {
const node = nodes.pop();
const nodeGroup = node.parentNode;
nodesGroup.removeChild(nodeGroup);
}
updateNodePositions();
});
function updateEllipseParams() {
ellipseCX = parseInt(cxInput.value);
ellipseCY = parseInt(cyInput.value);
ellipseRX = parseInt(rxInput.value);
ellipseRY = parseInt(ryInput.value);
ellipseRotate = parseInt(rotateInput.value);
gsap.to(ellipse, {
duration: 0.3,
attr: {
cx: ellipseCX,
cy: ellipseCY,
rx: ellipseRX,
ry: ellipseRY
}
});
gsap.to(ellipseGroup, {
duration: 0.3,
rotation: ellipseRotate,
transformOrigin: `${ellipseCX}px ${ellipseCY}px`
});
updateNodePositions();
}
function setupDraggable(node) {
node.addEventListener('mousedown', (e) => {
e.stopPropagation();
draggingNode = node;
const pt = svg.createSVGPoint();
pt.x = node.cx.baseVal.value;
pt.y = node.cy.baseVal.value;
const rotateRad = -ellipseRotate * Math.PI / 180;
const cos = Math.cos(rotateRad);
const sin = Math.sin(rotateRad);
const relX = pt.x - ellipseCX;
const relY = pt.y - ellipseCY;
const unrotatedX = relX * cos - relY * sin;
const unrotatedY = relX * sin + relY * cos;
initialAngle = Math.atan2(unrotatedY, unrotatedX);
const mousePt = svg.createSVGPoint();
mousePt.x = e.clientX;
mousePt.y = e.clientY;
const cursorPt = mousePt.matrixTransform(svg.getScreenCTM().inverse());
const mouseRelX = cursorPt.x - ellipseCX;
const mouseRelY = cursorPt.y - ellipseCY;
const mouseUnrotatedX = mouseRelX * cos - mouseRelY * sin;
const mouseUnrotatedY = mouseRelX * sin + mouseRelY * cos;
startAngle = Math.atan2(mouseUnrotatedY, mouseUnrotatedX);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
}
function onMouseMove(e) {
if (!draggingNode) return;
const pt = svg.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
const cursorPt = pt.matrixTransform(svg.getScreenCTM().inverse());
const mouseRelX = cursorPt.x - ellipseCX;
const mouseRelY = cursorPt.y - ellipseCY;
const rotateRad = -ellipseRotate * Math.PI / 180;
const cos = Math.cos(rotateRad);
const sin = Math.sin(rotateRad);
const mouseUnrotatedX = mouseRelX * cos - mouseRelY * sin;
const mouseUnrotatedY = mouseRelX * sin + mouseRelY * cos;
const currentAngle = Math.atan2(mouseUnrotatedY, mouseUnrotatedX);
let angleDiff = currentAngle - startAngle;
if (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
if (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
const newBaseAngle = initialAngle + angleDiff;
updateNodePositions(newBaseAngle);
}
function onMouseUp() {
draggingNode = null;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
function updateNodePositions(baseAngle = 0) {
const count = nodes.length;
if (count === 0) return;
const angleStep = (Math.PI * 2) / count;
const rotateRad = ellipseRotate * Math.PI / 180;
const cos = Math.cos(rotateRad);
const sin = Math.sin(rotateRad);
nodes.forEach((node, index) => {
const angle = baseAngle + (angleStep * index);
const unrotatedX = ellipseRX * Math.cos(angle);
const unrotatedY = ellipseRY * Math.sin(angle);
const rotatedX = unrotatedX * cos - unrotatedY * sin;
const rotatedY = unrotatedX * sin + unrotatedY * cos;
const x = ellipseCX + rotatedX;
const y = ellipseCY + rotatedY;
gsap.to(node, {
duration: 0.1,
attr: {
cx: x,
cy: y
}
});
const nodeGroup = node.parentNode;
const nodeText = nodeGroup.querySelector('text');
if (nodeText) {
gsap.to(nodeText, {
duration: 0.1,
attr: {
x: x,
y: y
}
});
}
});
}
完整代码
gitee.com/south-mount…