背景
最近在开发一个 ECharts 图表的场景, 有很多曲线, 但是没有公式, 不知道这个曲线怎么生成的, 但是最好做到跟这个效果是一致的; 传统方式可能是通过 多个点位 去拟合 , 实际效果会存在一些偏差,或者曲线不够 光滑圆润等问题
我知道起点, 终点, 然后 曲线对应的最高顶点坐标, 现在要绘制期望的曲线, 将一个曲线分为2段来看, 左侧 2个点 + 一个控制点, 刚好就是 一个贝塞尔曲线可以绘制的, 右边同理。
那么 二次贝塞尔曲线 就刚好满足我们的需求
ECharts中通常都是 静态的配置 Options , 一般比较少去做拖拽的场景, 现在 我们还是先将 ECharts 骨架搭建出来, 然后就是点位。
设定我们的目标
目标:
- 4个可拖动的控制点: 起点, 控制点A, 控制点B, 终点
- 控制手柄
- 实时更新贝塞尔曲线
- 鼠标拖动任意控制点或者控制柄, 更新曲线,更新视图。
- 配置最终可以复用,导出
实现
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>交互式贝塞尔曲线 - 带导出功能</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #f0f2f5;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
font-family: Arial, sans-serif;
padding: 20px;
}
.container {
background: #fff;
border-radius: 16px;
box-shadow: 0 8px 30px rgba(0,0,0,0.12);
padding: 24px;
max-width: 1100px;
width: 100%;
}
#chart {
width: 100%;
height: 520px;
border-radius: 8px;
background: #fafbfc;
cursor: default;
user-select: none;
}
.toolbar {
margin-top: 16px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
background: #f8f9fa;
border-radius: 8px;
flex-wrap: wrap;
gap: 10px;
}
.toolbar .left {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
}
.toolbar .left .dot {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
}
.toolbar .left .dot.start { background: #91CC75; }
.toolbar .left .dot.ctrlA { background: #5470C6; }
.toolbar .left .dot.ctrlB { background: #fac858; }
.toolbar .left .dot.end { background: #ee6666; }
.toolbar .left .hint {
color: #999;
font-size: 12px;
}
.toolbar .left .hint strong {
color: #5470C6;
}
.toolbar .right {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn {
padding: 8px 20px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.btn-primary {
background: #5470C6;
color: #fff;
}
.btn-primary:hover {
background: #3a5aad;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(84,112,198,0.3);
}
.btn-success {
background: #91CC75;
color: #fff;
}
.btn-success:hover {
background: #7ab85c;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(145,204,117,0.3);
}
.btn-warning {
background: #fac858;
color: #333;
}
.btn-warning:hover {
background: #e8b840;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(250,200,88,0.3);
}
.btn-danger {
background: #ee6666;
color: #fff;
}
.btn-danger:hover {
background: #d45555;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(238,102,102,0.3);
}
/* 导出弹窗 */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
justify-content: center;
align-items: center;
backdrop-filter: blur(4px);
}
.modal-overlay.active {
display: flex;
}
.modal {
background: #fff;
border-radius: 16px;
padding: 28px 32px;
max-width: 800px;
width: 95%;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
animation: modalIn 0.25s ease;
}
@keyframes modalIn {
from { opacity: 0; transform: scale(0.95) translateY(20px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 16px;
border-bottom: 2px solid #f0f0f0;
}
.modal-header h2 {
font-size: 18px;
color: #333;
display: flex;
align-items: center;
gap: 10px;
}
.modal-header .close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #999;
padding: 0 8px;
transition: color 0.2s;
}
.modal-header .close-btn:hover {
color: #333;
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 16px 0;
}
.modal-body .section {
margin-bottom: 16px;
}
.modal-body .section-title {
font-size: 13px;
font-weight: 600;
color: #666;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 8px;
}
.modal-body .section-title .badge {
background: #5470C6;
color: #fff;
font-size: 10px;
padding: 1px 10px;
border-radius: 12px;
}
.modal-body .coord-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px 20px;
background: #f8f9fa;
padding: 10px 14px;
border-radius: 6px;
font-family: 'Courier New', monospace;
font-size: 13px;
}
.modal-body .coord-grid .label {
color: #888;
font-weight: 500;
}
.modal-body .coord-grid .value {
color: #333;
text-align: right;
}
.modal-body .formula-box {
background: #1e1e2e;
color: #cdd6f4;
padding: 14px 18px;
border-radius: 8px;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.8;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
.modal-body .formula-box .comment {
color: #6c7086;
}
.modal-body .formula-box .keyword {
color: #cba6f7;
}
.modal-body .formula-box .number {
color: #fab387;
}
.modal-body .formula-box .string {
color: #a6e3a1;
}
.modal-body .formula-box .func {
color: #89b4fa;
}
.modal-footer {
padding-top: 16px;
border-top: 2px solid #f0f0f0;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.modal-footer .btn {
padding: 8px 24px;
}
.toast {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
background: #333;
color: #fff;
padding: 10px 28px;
border-radius: 8px;
font-size: 14px;
z-index: 2000;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.toast.show {
opacity: 1;
}
/* 状态指示器 */
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #91CC75;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
</style>
</head>
<body>
<div class="container">
<div id="chart"></div>
<div class="toolbar">
<div class="left">
<span><span class="dot start"></span> 起点</span>
<span><span class="dot ctrlA"></span> 控制点A</span>
<span><span class="dot ctrlB"></span> 控制点B</span>
<span><span class="dot end"></span> 终点</span>
<span class="hint">| 🖱️ <strong>拖拽</strong> 控制点或控制柄调整曲线</span>
<span class="status-dot" id="statusDot" style="margin-left:4px;"></span>
</div>
<div class="right">
<button class="btn btn-success" onclick="exportCode()">📄 导出代码</button>
<button class="btn btn-primary" onclick="exportData()">📊 导出数据</button>
<button class="btn btn-warning" onclick="resetCurve()">↺ 重置</button>
</div>
</div>
</div>
<!-- 导出弹窗 -->
<div class="modal-overlay" id="exportModal">
<div class="modal">
<div class="modal-header">
<h2>📄 导出贝塞尔曲线</h2>
<button class="close-btn" onclick="closeModal()">✕</button>
</div>
<div class="modal-body" id="modalBody">
<!-- 动态内容 -->
</div>
<div class="modal-footer">
<button class="btn btn-primary" onclick="copyToClipboard()">📋 复制全部</button>
<button class="btn btn-success" onclick="downloadAsFile()">💾 下载文件</button>
<button class="btn" style="background:#f0f0f0;color:#666;" onclick="closeModal()">关闭</button>
</div>
</div>
</div>
<!-- Toast -->
<div class="toast" id="toast"></div>
<script>
// ============================================================
// 1. 初始化 ECharts
// ============================================================
const chartDom = document.getElementById('chart');
const chart = echarts.init(chartDom);
// ============================================================
// 2. 贝塞尔曲线参数
// ============================================================
const state = {
points: {
start: [0.1, 0.1],
ctrlA: [0.3, 0.7],
ctrlB: [0.7, 0.7],
end: [0.9, 0.1]
},
dragging: null,
dragOffset: [0, 0],
isDragging: false
};
// ============================================================
// 3. 贝塞尔曲线计算
// ============================================================
function cubicBezier(t, p0, p1, p2, p3) {
const u = 1 - t;
const x = u*u*u * p0[0] + 3*u*u*t * p1[0] + 3*u*t*t * p2[0] + t*t*t * p3[0];
const y = u*u*u * p0[1] + 3*u*u*t * p1[1] + 3*u*t*t * p2[1] + t*t*t * p3[1];
return [x, y];
}
function generateBezierData(p0, p1, p2, p3, steps = 200) {
const data = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
data.push(cubicBezier(t, p0, p1, p2, p3));
}
return data;
}
function getHandleEndpoint(ctrlPoint, targetPoint, length = 0.12) {
const dx = targetPoint[0] - ctrlPoint[0];
const dy = targetPoint[1] - ctrlPoint[1];
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist < 0.001) return [ctrlPoint[0], ctrlPoint[1]];
const ratio = length / dist;
return [ctrlPoint[0] + dx * ratio, ctrlPoint[1] + dy * ratio];
}
// ============================================================
// 4. 渲染
// ============================================================
function render() {
const p = state.points;
const p0 = p.start, p1 = p.ctrlA, p2 = p.ctrlB, p3 = p.end;
const curveData = generateBezierData(p0, p1, p2, p3);
const handleLen = 0.12;
const handleAEnd = getHandleEndpoint(p1, p0, handleLen);
const handleBEnd = getHandleEndpoint(p2, p3, handleLen);
const option = {
title: {
text: '🖱️ 交互式贝塞尔曲线',
subtext: '拖拽控制点或控制柄调整曲线 | 控制柄可自由旋转',
left: 'center',
top: 6,
textStyle: { fontSize: 16, fontWeight: 'bold' },
subtextStyle: { fontSize: 12, color: '#888' }
},
tooltip: {
trigger: 'item',
formatter: (params) => {
if (params.seriesName === '贝塞尔曲线') {
return `📍 曲线上的点<br/>x: ${params.data[0].toFixed(3)}<br/>y: ${params.data[1].toFixed(3)}`;
}
return params.name || '';
}
},
grid: { left: 50, right: 30, bottom: 40, top: 60 },
xAxis: {
type: 'value',
min: -0.05,
max: 1.05,
splitLine: { show: true, lineStyle: { color: '#f0f0f0', type: 'dashed' } },
axisLabel: { fontSize: 11 }
},
yAxis: {
type: 'value',
min: -0.05,
max: 1.05,
splitLine: { show: true, lineStyle: { color: '#f0f0f0', type: 'dashed' } },
axisLabel: { fontSize: 11 }
},
series: [
{
name: '贝塞尔曲线',
type: 'line',
data: curveData,
smooth: true,
symbol: 'none',
lineStyle: { width: 3.5, color: '#5470C6' },
areaStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(84,112,198,0.15)' },
{ offset: 1, color: 'rgba(84,112,198,0.02)' }
]
}
},
z: 1
},
{
name: '控制多边形',
type: 'line',
data: [p0, p1, p2, p3],
smooth: false,
symbol: 'none',
lineStyle: { color: '#ccc', type: 'dashed', width: 1.5 },
z: 1
},
{
name: '控制柄A',
type: 'line',
data: [p1, handleAEnd],
smooth: false,
symbol: 'none',
lineStyle: { color: '#5470C6', width: 2, opacity: 0.6 },
z: 2
},
{
name: '控制柄B',
type: 'line',
data: [p2, handleBEnd],
smooth: false,
symbol: 'none',
lineStyle: { color: '#fac858', width: 2, opacity: 0.6 },
z: 2
},
{
name: '控制柄端点A',
type: 'scatter',
data: [handleAEnd],
symbol: 'circle',
symbolSize: 14,
itemStyle: {
color: '#5470C6',
borderColor: '#fff',
borderWidth: 2,
shadowBlur: 8,
shadowColor: 'rgba(84,112,198,0.4)'
},
label: { show: true, formatter: '柄A', fontSize: 9, color: '#5470C6', position: 'bottom', fontWeight: 'bold' },
z: 10
},
{
name: '控制柄端点B',
type: 'scatter',
data: [handleBEnd],
symbol: 'circle',
symbolSize: 14,
itemStyle: {
color: '#fac858',
borderColor: '#fff',
borderWidth: 2,
shadowBlur: 8,
shadowColor: 'rgba(250,200,88,0.4)'
},
label: { show: true, formatter: '柄B', fontSize: 9, color: '#d48265', position: 'bottom', fontWeight: 'bold' },
z: 10
},
{
name: '控制点A',
type: 'scatter',
data: [p1],
symbol: 'circle',
symbolSize: 18,
itemStyle: {
color: '#5470C6',
borderColor: '#fff',
borderWidth: 3,
shadowBlur: 10,
shadowColor: 'rgba(84,112,198,0.5)'
},
label: { show: true, formatter: 'A', fontSize: 11, color: '#fff', fontWeight: 'bold' },
z: 10
},
{
name: '控制点B',
type: 'scatter',
data: [p2],
symbol: 'circle',
symbolSize: 18,
itemStyle: {
color: '#fac858',
borderColor: '#fff',
borderWidth: 3,
shadowBlur: 10,
shadowColor: 'rgba(250,200,88,0.5)'
},
label: { show: true, formatter: 'B', fontSize: 11, color: '#333', fontWeight: 'bold' },
z: 10
},
{
name: '起点',
type: 'scatter',
data: [p0],
symbol: 'pin',
symbolSize: 32,
itemStyle: { color: '#91CC75', borderColor: '#fff', borderWidth: 2 },
label: { show: true, formatter: '起点', fontSize: 9, color: '#333', position: 'bottom' },
z: 10
},
{
name: '终点',
type: 'scatter',
data: [p3],
symbol: 'pin',
symbolSize: 32,
itemStyle: { color: '#ee6666', borderColor: '#fff', borderWidth: 2 },
label: { show: true, formatter: '终点', fontSize: 9, color: '#333', position: 'bottom' },
z: 10
}
]
};
chart.setOption(option, true);
}
// ============================================================
// 5. 交互事件
// ============================================================
function getMousePos(e) {
const rect = chartDom.getBoundingClientRect();
return [e.clientX - rect.left, e.clientY - rect.top];
}
function dataToPixel(dataPos) {
return chart.convertToPixel('grid', dataPos);
}
function pixelToData(pixelPos) {
return chart.convertFromPixel('grid', pixelPos);
}
function hitTest(pixelPos, targetDataPos, threshold = 20) {
const pixel = dataToPixel(targetDataPos);
const dx = pixelPos[0] - pixel[0];
const dy = pixelPos[1] - pixel[1];
return Math.sqrt(dx*dx + dy*dy) < threshold;
}
function getDragTarget(pixelPos) {
const p = state.points;
const threshold = 25;
const handleLen = 0.12;
const handleAEnd = getHandleEndpoint(p.ctrlA, p.start, handleLen);
const handleBEnd = getHandleEndpoint(p.ctrlB, p.end, handleLen);
if (hitTest(pixelPos, handleAEnd, threshold)) return 'handleA';
if (hitTest(pixelPos, handleBEnd, threshold)) return 'handleB';
if (hitTest(pixelPos, p.ctrlA, threshold)) return 'ctrlA';
if (hitTest(pixelPos, p.ctrlB, threshold)) return 'ctrlB';
if (hitTest(pixelPos, p.start, threshold)) return 'start';
if (hitTest(pixelPos, p.end, threshold)) return 'end';
return null;
}
let dragTarget = null;
chartDom.addEventListener('mousedown', (e) => {
const pixelPos = getMousePos(e);
const target = getDragTarget(pixelPos);
if (target) {
dragTarget = target;
state.isDragging = true;
const dataPos = state.points[target] || getHandleEndpoint(
state.points[target === 'handleA' ? 'ctrlA' : 'ctrlB'],
state.points[target === 'handleA' ? 'start' : 'end'],
0.12
);
const pixel = dataToPixel(dataPos);
state.dragOffset = [pixelPos[0] - pixel[0], pixelPos[1] - pixel[1]];
chartDom.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
e.preventDefault();
}
});
document.addEventListener('mousemove', (e) => {
const pixelPos = getMousePos(e);
if (state.isDragging && dragTarget) {
const newPixelX = pixelPos[0] - state.dragOffset[0];
const newPixelY = pixelPos[1] - state.dragOffset[1];
const newDataPos = pixelToData([newPixelX, newPixelY]);
const clamped = [
Math.max(0, Math.min(1, newDataPos[0])),
Math.max(0, Math.min(1, newDataPos[1]))
];
const p = state.points;
if (dragTarget === 'start') {
p.start = clamped;
} else if (dragTarget === 'end') {
p.end = clamped;
} else if (dragTarget === 'ctrlA') {
p.ctrlA = clamped;
} else if (dragTarget === 'ctrlB') {
p.ctrlB = clamped;
} else if (dragTarget === 'handleA') {
const ctrlAPixel = dataToPixel(p.ctrlA);
const dx = newPixelX - ctrlAPixel[0];
const dy = newPixelY - ctrlAPixel[1];
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist > 5) {
const newCtrlA = [
p.ctrlA[0] + (newDataPos[0] - p.ctrlA[0]) * 0.8,
p.ctrlA[1] + (newDataPos[1] - p.ctrlA[1]) * 0.8
];
p.ctrlA = [Math.max(0, Math.min(1, newCtrlA[0])), Math.max(0, Math.min(1, newCtrlA[1]))];
}
} else if (dragTarget === 'handleB') {
const ctrlBPixel = dataToPixel(p.ctrlB);
const dx = newPixelX - ctrlBPixel[0];
const dy = newPixelY - ctrlBPixel[1];
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist > 5) {
const newCtrlB = [
p.ctrlB[0] + (newDataPos[0] - p.ctrlB[0]) * 0.8,
p.ctrlB[1] + (newDataPos[1] - p.ctrlB[1]) * 0.8
];
p.ctrlB = [Math.max(0, Math.min(1, newCtrlB[0])), Math.max(0, Math.min(1, newCtrlB[1]))];
}
}
render();
e.preventDefault();
} else {
const target = getDragTarget(pixelPos);
chartDom.style.cursor = target ? 'grab' : 'default';
}
});
document.addEventListener('mouseup', () => {
if (state.isDragging) {
state.isDragging = false;
dragTarget = null;
chartDom.style.cursor = 'default';
document.body.style.userSelect = '';
}
});
chartDom.addEventListener('selectstart', (e) => e.preventDefault());
// ============================================================
// 6. 导出功能
// ============================================================
function getCurrentData() {
const p = state.points;
return {
start: p.start,
ctrlA: p.ctrlA,
ctrlB: p.ctrlB,
end: p.end
};
}
function formatNumber(v) {
return Number(v.toFixed(4));
}
function generateFormula(data) {
const { start, ctrlA, ctrlB, end } = data;
const [p0x, p0y] = start.map(formatNumber);
const [p1x, p1y] = ctrlA.map(formatNumber);
const [p2x, p2y] = ctrlB.map(formatNumber);
const [p3x, p3y] = end.map(formatNumber);
// 贝塞尔公式展开
// B(t) = (1-t)³P0 + 3(1-t)²tP1 + 3(1-t)t²P2 + t³P3
// x(t) = (1-t)³*x0 + 3(1-t)²t*x1 + 3(1-t)t²*x2 + t³*x3
// 展开为: x(t) = a*t³ + b*t² + c*t + d
const ax = -p0x + 3*p1x - 3*p2x + p3x;
const bx = 3*p0x - 6*p1x + 3*p2x;
const cx = -3*p0x + 3*p1x;
const dx = p0x;
const ay = -p0y + 3*p1y - 3*p2y + p3y;
const by = 3*p0y - 6*p1y + 3*p2y;
const cy = -3*p0y + 3*p1y;
const dy = p0y;
return {
coefficients: { ax, bx, cx, dx, ay, by, cy, dy },
formula: `x(t) = ${ax.toFixed(4)}t³ + ${bx.toFixed(4)}t² + ${cx.toFixed(4)}t + ${dx.toFixed(4)}\ny(t) = ${ay.toFixed(4)}t³ + ${by.toFixed(4)}t² + ${cy.toFixed(4)}t + ${dy.toFixed(4)}`,
points: { start, ctrlA, ctrlB, end }
};
}
function generateExportCode(data) {
const { start, ctrlA, ctrlB, end } = data;
const [p0x, p0y] = start.map(formatNumber);
const [p1x, p1y] = ctrlA.map(formatNumber);
const [p2x, p2y] = ctrlB.map(formatNumber);
const [p3x, p3y] = end.map(formatNumber);
return `// ============================================================
// 贝塞尔曲线配置 (由交互式工具导出)
// 生成时间: ${new Date().toLocaleString()}
// ============================================================
// 1. 控制点坐标
const controlPoints = {
start: [${p0x}, ${p0y}], // 起点
ctrlA: [${p1x}, ${p1y}], // 控制点A
ctrlB: [${p2x}, ${p2y}], // 控制点B
end: [${p3x}, ${p3y}] // 终点
};
// 2. 贝塞尔曲线计算函数 (三次贝塞尔)
function cubicBezier(t, p0, p1, p2, p3) {
const u = 1 - t;
return [
u*u*u * p0[0] + 3*u*u*t * p1[0] + 3*u*t*t * p2[0] + t*t*t * p3[0],
u*u*u * p0[1] + 3*u*u*t * p1[1] + 3*u*t*t * p2[1] + t*t*t * p3[1]
];
}
// 3. 生成曲线数据 (200个点)
function generateBezierCurve(steps = 200) {
const { start, ctrlA, ctrlB, end } = controlPoints;
const data = [];
for (let i = 0; i <= steps; i++) {
const t = i / steps;
data.push(cubicBezier(t, start, ctrlA, ctrlB, end));
}
return data;
}
// 4. 使用示例
const curveData = generateBezierCurve(200);
console.log('曲线数据:', curveData);
// 5. ECharts 使用示例
/*
const option = {
series: [{
type: 'line',
data: curveData,
smooth: true,
lineStyle: { width: 3, color: '#5470C6' }
}]
};
*/
`;
}
// 导出数据(JSON格式)
function exportData() {
const data = getCurrentData();
const formula = generateFormula(data);
const exportObj = {
version: '1.0',
exportTime: new Date().toISOString(),
controlPoints: {
start: data.start,
ctrlA: data.ctrlA,
ctrlB: data.ctrlB,
end: data.end
},
formula: {
x: formula.formula.split('\\n')[0],
y: formula.formula.split('\\n')[1]
},
coefficients: formula.coefficients
};
const jsonStr = JSON.stringify(exportObj, null, 2);
// 显示在弹窗中
showModal(`
<div class="section">
<div class="section-title">📊 导出数据 (JSON)</div>
<div class="formula-box">${jsonStr}</div>
</div>
<div class="section">
<div class="section-title">📐 控制点坐标</div>
<div class="coord-grid">
<span class="label">🟢 起点</span>
<span class="value">(${data.start[0].toFixed(4)}, ${data.start[1].toFixed(4)})</span>
<span class="label">🔵 控制点A</span>
<span class="value">(${data.ctrlA[0].toFixed(4)}, ${data.ctrlA[1].toFixed(4)})</span>
<span class="label">🟡 控制点B</span>
<span class="value">(${data.ctrlB[0].toFixed(4)}, ${data.ctrlB[1].toFixed(4)})</span>
<span class="label">🔴 终点</span>
<span class="value">(${data.end[0].toFixed(4)}, ${data.end[1].toFixed(4)})</span>
</div>
</div>
`, 'json');
}
// 导出代码
function exportCode() {
const data = getCurrentData();
const code = generateExportCode(data);
showModal(`
<div class="section">
<div class="section-title">📄 可复用代码 <span class="badge">复制即用</span></div>
<div class="formula-box">${escapeHtml(code)}</div>
</div>
<div class="section">
<div class="section-title">📐 当前控制点</div>
<div class="coord-grid">
<span class="label">🟢 起点</span>
<span class="value">(${data.start[0].toFixed(4)}, ${data.start[1].toFixed(4)})</span>
<span class="label">🔵 控制点A</span>
<span class="value">(${data.ctrlA[0].toFixed(4)}, ${data.ctrlA[1].toFixed(4)})</span>
<span class="label">🟡 控制点B</span>
<span class="value">(${data.ctrlB[0].toFixed(4)}, ${data.ctrlB[1].toFixed(4)})</span>
<span class="label">🔴 终点</span>
<span class="value">(${data.end[0].toFixed(4)}, ${data.end[1].toFixed(4)})</span>
</div>
</div>
`, 'code');
}
// 重置曲线
function resetCurve() {
state.points = {
start: [0.1, 0.1],
ctrlA: [0.3, 0.7],
ctrlB: [0.7, 0.7],
end: [0.9, 0.1]
};
render();
showToast('↺ 已重置为默认曲线');
}
// ============================================================
// 7. 弹窗控制
// ============================================================
let modalContent = '';
let modalType = '';
function showModal(content, type = 'code') {
modalContent = content;
modalType = type;
document.getElementById('modalBody').innerHTML = content;
document.getElementById('exportModal').classList.add('active');
}
function closeModal() {
document.getElementById('exportModal').classList.remove('active');
}
function escapeHtml(str) {
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
// 复制到剪贴板
function copyToClipboard() {
let text = '';
if (modalType === 'code') {
const data = getCurrentData();
text = generateExportCode(data);
} else {
const data = getCurrentData();
const formula = generateFormula(data);
text = JSON.stringify({
version: '1.0',
exportTime: new Date().toISOString(),
controlPoints: data,
formula: formula.formula
}, null, 2);
}
navigator.clipboard.writeText(text).then(() => {
showToast('✅ 已复制到剪贴板!');
}).catch(() => {
// 降级方案
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showToast('✅ 已复制到剪贴板!');
});
}
// 下载文件
function downloadAsFile() {
let content = '';
let filename = '';
if (modalType === 'code') {
const data = getCurrentData();
content = generateExportCode(data);
filename = `bezier-curve-${Date.now()}.js`;
} else {
const data = getCurrentData();
const formula = generateFormula(data);
content = JSON.stringify({
version: '1.0',
exportTime: new Date().toISOString(),
controlPoints: data,
formula: formula.formula
}, null, 2);
filename = `bezier-data-${Date.now()}.json`;
}
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast(`💾 已下载: ${filename}`);
}
// ============================================================
// 8. Toast 通知
// ============================================================
let toastTimer = null;
function showToast(msg) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
el.classList.remove('show');
}, 2500);
}
// ============================================================
// 9. 启动
// ============================================================
render();
window.addEventListener('resize', () => { chart.resize(); render(); });
// 点击弹窗外部关闭
document.getElementById('exportModal').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeModal();
});
console.log('✅ 交互式贝塞尔曲线已启动!');
console.log('📄 点击"导出代码"获取可复用的 JavaScript 代码');
console.log('📊 点击"导出数据"获取 JSON 格式的控制点数据');
</script>
</body>
</html>