echarts 之 二次贝塞尔曲线,可拖动,可导出配置复用

12 阅读10分钟

ChatGPT Image 2026年7月6日 19_32_11.png

背景

最近在开发一个 ECharts 图表的场景, 有很多曲线, 但是没有公式, 不知道这个曲线怎么生成的, 但是最好做到跟这个效果是一致的; 传统方式可能是通过 多个点位 去拟合 , 实际效果会存在一些偏差,或者曲线不够 光滑圆润等问题

我知道起点, 终点, 然后 曲线对应的最高顶点坐标, 现在要绘制期望的曲线, 将一个曲线分为2段来看, 左侧 2个点 + 一个控制点, 刚好就是 一个贝塞尔曲线可以绘制的, 右边同理。

那么 二次贝塞尔曲线 就刚好满足我们的需求

ECharts中通常都是 静态的配置 Options , 一般比较少去做拖拽的场景, 现在 我们还是先将 ECharts 骨架搭建出来, 然后就是点位。

设定我们的目标

目标:

  1. 4个可拖动的控制点: 起点, 控制点A, 控制点B, 终点
  2. 控制手柄
  3. 实时更新贝塞尔曲线
  4. 鼠标拖动任意控制点或者控制柄, 更新曲线,更新视图。
  5. 配置最终可以复用,导出

实现

<!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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

// 复制到剪贴板
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>

在线运行效果