跪拜 Guibai
← Back to the summary

A Draggable Bezier Curve Editor for ECharts That Exports Reusable Configs

Background

Recently, while developing an ECharts chart scenario, there were many curves but no formulas, and it was unclear how the curves were generated. However, the result needed to match the desired effect as closely as possible. The traditional approach might involve fitting through multiple points, but the actual effect may have some deviations, or the curve may not be smooth and rounded enough.

I know the start point, the end point, and the coordinates of the highest vertex corresponding to the curve. Now I need to draw the desired curve. Looking at the curve as two segments, the left side has 2 points + 1 control point, which is exactly what a Bezier curve can draw. The same logic applies to the right side.

So, a quadratic Bezier curve perfectly meets our needs.

In ECharts, configurations are usually static Options, and drag-and-drop scenarios are rarely implemented. Now, let's first build the ECharts skeleton, and then the points.

Set our goals:

Goals:

  1. 4 draggable control points: Start point, Control point A, Control point B, End point
  2. Control handles
  3. Real-time update of the Bezier curve
  4. Dragging any control point or handle updates the curve and the view.
  5. The configuration can ultimately be reused and exported.

Implementation

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8" />
    <title>Interactive Bezier Curve - with Export Function</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);
        }

        /* Export Modal */
        .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 Indicator */
        .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> Start</span>
            <span><span class="dot ctrlA"></span> Control A</span>
            <span><span class="dot ctrlB"></span> Control B</span>
            <span><span class="dot end"></span> End</span>
            <span class="hint">| 🖱️ <strong>Drag</strong> control points or handles to adjust the curve</span>
            <span class="status-dot" id="statusDot" style="margin-left:4px;"></span>
        </div>
        <div class="right">
            <button class="btn btn-success" onclick="exportCode()">📄 Export Code</button>
            <button class="btn btn-primary" onclick="exportData()">📊 Export Data</button>
            <button class="btn btn-warning" onclick="resetCurve()">↺ Reset</button>
        </div>
    </div>
</div>

<!-- Export Modal -->
<div class="modal-overlay" id="exportModal">
    <div class="modal">
        <div class="modal-header">
            <h2>📄 Export Bezier Curve</h2>
            <button class="close-btn" onclick="closeModal()">✕</button>
        </div>
        <div class="modal-body" id="modalBody">
            <!-- Dynamic content -->
        </div>
        <div class="modal-footer">
            <button class="btn btn-primary" onclick="copyToClipboard()">📋 Copy All</button>
            <button class="btn btn-success" onclick="downloadAsFile()">💾 Download File</button>
            <button class="btn" style="background:#f0f0f0;color:#666;" onclick="closeModal()">Close</button>
        </div>
    </div>
</div>

<!-- Toast -->
<div class="toast" id="toast"></div>

<script>
// ============================================================
// 1. Initialize ECharts
// ============================================================
const chartDom = document.getElementById('chart');
const chart = echarts.init(chartDom);

// ============================================================
// 2. Bezier Curve Parameters
// ============================================================
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. Bezier Curve Calculation
// ============================================================
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. Render
// ============================================================
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: '🖱️ Interactive Bezier Curve',
            subtext: 'Drag control points or handles to adjust the curve | Handles can rotate freely',
            left: 'center',
            top: 6,
            textStyle: { fontSize: 16, fontWeight: 'bold' },
            subtextStyle: { fontSize: 12, color: '#888' }
        },
        tooltip: {
            trigger: 'item',
            formatter: (params) => {
                if (params.seriesName === 'Bezier Curve') {
                    return `📍 Point on curve<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: 'Bezier Curve',
                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: 'Control Polygon',
                type: 'line',
                data: [p0, p1, p2, p3],
                smooth: false,
                symbol: 'none',
                lineStyle: { color: '#ccc', type: 'dashed', width: 1.5 },
                z: 1
            },
            {
                name: 'Handle A',
                type: 'line',
                data: [p1, handleAEnd],
                smooth: false,
                symbol: 'none',
                lineStyle: { color: '#5470C6', width: 2, opacity: 0.6 },
                z: 2
            },
            {
                name: 'Handle B',
                type: 'line',
                data: [p2, handleBEnd],
                smooth: false,
                symbol: 'none',
                lineStyle: { color: '#fac858', width: 2, opacity: 0.6 },
                z: 2
            },
            {
                name: 'Handle Endpoint 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: 'Handle A', fontSize: 9, color: '#5470C6', position: 'bottom', fontWeight: 'bold' },
                z: 10
            },
            {
                name: 'Handle Endpoint 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: 'Handle B', fontSize: 9, color: '#d48265', position: 'bottom', fontWeight: 'bold' },
                z: 10
            },
            {
                name: 'Control Point 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: 'Control Point 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: 'Start Point',
                type: 'scatter',
                data: [p0],
                symbol: 'pin',
                symbolSize: 32,
                itemStyle: { color: '#91CC75', borderColor: '#fff', borderWidth: 2 },
                label: { show: true, formatter: 'Start', fontSize: 9, color: '#333', position: 'bottom' },
                z: 10
            },
            {
                name: 'End Point',
                type: 'scatter',
                data: [p3],
                symbol: 'pin',
                symbolSize: 32,
                itemStyle: { color: '#ee6666', borderColor: '#fff', borderWidth: 2 },
                label: { show: true, formatter: 'End', fontSize: 9, color: '#333', position: 'bottom' },
                z: 10
            }
        ]
    };

    chart.setOption(option, true);
}

// ============================================================
// 5. Interaction Events
// ============================================================
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. Export Functions
// ============================================================
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);

    // Bezier formula expansion
    // 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
    // Expanded to: 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 `// ============================================================
// Bezier Curve Configuration (Exported by Interactive Tool)
// Export Time: ${new Date().toLocaleString()}
// ============================================================

// 1. Control Point Coordinates
const controlPoints = {
    start: [${p0x}, ${p0y}],   // Start Point
    ctrlA: [${p1x}, ${p1y}],   // Control Point A
    ctrlB: [${p2x}, ${p2y}],   // Control Point B
    end: [${p3x}, ${p3y}]      // End Point
};

// 2. Bezier Curve Calculation Function (Cubic Bezier)
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. Generate Curve Data (200 points)
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. Usage Example
const curveData = generateBezierCurve(200);
console.log('Curve Data:', curveData);

// 5. ECharts Usage Example
/*
const option = {
    series: [{
        type: 'line',
        data: curveData,
        smooth: true,
        lineStyle: { width: 3, color: '#5470C6' }
    }]
};
*/
`;
}

// Export Data (JSON format)
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);

    // Display in modal
    showModal(`
        <div class="section">
            <div class="section-title">📊 Exported Data (JSON)</div>
            <div class="formula-box">${jsonStr}</div>
        </div>
        <div class="section">
            <div class="section-title">📐 Control Point Coordinates</div>
            <div class="coord-grid">
                <span class="label">🟢 Start</span>
                <span class="value">(${data.start[0].toFixed(4)}, ${data.start[1].toFixed(4)})</span>
                <span class="label">🔵 Control A</span>
                <span class="value">(${data.ctrlA[0].toFixed(4)}, ${data.ctrlA[1].toFixed(4)})</span>
                <span class="label">🟡 Control B</span>
                <span class="value">(${data.ctrlB[0].toFixed(4)}, ${data.ctrlB[1].toFixed(4)})</span>
                <span class="label">🔴 End</span>
                <span class="value">(${data.end[0].toFixed(4)}, ${data.end[1].toFixed(4)})</span>
            </div>
        </div>
    `, 'json');
}

// Export Code
function exportCode() {
    const data = getCurrentData();
    const code = generateExportCode(data);

    showModal(`
        <div class="section">
            <div class="section-title">📄 Reusable Code <span class="badge">Copy & Use</span></div>
            <div class="formula-box">${escapeHtml(code)}</div>
        </div>
        <div class="section">
            <div class="section-title">📐 Current Control Points</div>
            <div class="coord-grid">
                <span class="label">🟢 Start</span>
                <span class="value">(${data.start[0].toFixed(4)}, ${data.start[1].toFixed(4)})</span>
                <span class="label">🔵 Control A</span>
                <span class="value">(${data.ctrlA[0].toFixed(4)}, ${data.ctrlA[1].toFixed(4)})</span>
                <span class="label">🟡 Control B</span>
                <span class="value">(${data.ctrlB[0].toFixed(4)}, ${data.ctrlB[1].toFixed(4)})</span>
                <span class="label">🔴 End</span>
                <span class="value">(${data.end[0].toFixed(4)}, ${data.end[1].toFixed(4)})</span>
            </div>
        </div>
    `, 'code');
}

// Reset Curve
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('↺ Reset to default curve');
}

// ============================================================
// 7. Modal Control
// ============================================================
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;');
}

// Copy to Clipboard
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('✅ Copied to clipboard!');
    }).catch(() => {
        // Fallback
        const textarea = document.createElement('textarea');
        textarea.value = text;
        document.body.appendChild(textarea);
        textarea.select();
        document.execCommand('copy');
        document.body.removeChild(textarea);
        showToast('✅ Copied to clipboard!');
    });
}

// Download File
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(`💾 Downloaded: ${filename}`);
}

// ============================================================
// 8. Toast Notification
// ============================================================
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. Start
// ============================================================
render();
window.addEventListener('resize', () => { chart.resize(); render(); });

// Click outside modal to close
document.getElementById('exportModal').addEventListener('click', (e) => {
    if (e.target === e.currentTarget) closeModal();
});

console.log('✅ Interactive Bezier Curve started!');
console.log('📄 Click "Export Code" to get reusable JavaScript code');
console.log('📊 Click "Export Data" to get JSON format control point data');
</script>
</body>
</html>

Online Running Effect

jcode

Comments

Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.

liucan233

How were the article's illustrations generated? What prompts did you use? Please share.

鸡翅喝可乐变成可乐鸡翅

Go to https://code.juejin.cn/ to add code snippets.