使用Canvas构建交互式历史曲线图表:完整指南与实现原理

496 阅读6分钟

该 HTML 文件是一款纯前端交互式历史数据曲线图工具,基于 Canvas API 实现数据可视化,支持切换时间范围(7 天 / 30 天 / 90 天)、生成随机波动数据、鼠标悬浮查看详情,同时显示关键指标(当前值、最高值、最低值),视觉上采用渐变背景与玻璃拟物风格,兼顾美观与实用性。


大家复制代码时,可能会因格式转换出现错乱,导致样式失效。建议先少量复制代码进行测试,若未能解决问题,私信回复源码两字,我会发送完整的压缩包给你。

演示效果

演示效果

演示效果

HTML&CSS


<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>历史曲线图表</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
        }

        body {
            display: flex;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            background: linear-gradient(135deg, #1a2a6c, #2a3c7f);
            color: #fff;
            padding: 20px;
        }

        .container {
            max-width: 1000px;
            width: 100%;
            background: rgba(255, 255, 255, 0.1);
            backdrop-filter: blur(10px);
            border-radius: 20px;
            padding: 30px;
            box-shadow: 0 15px 35px rgba(0, 0, 0, 0.2);
        }

        header {
            text-align: center;
            margin-bottom: 30px;
        }

        h1 {
            font-size: 2.5rem;
            margin-bottom: 10px;
            background: linear-gradient(to right, #00c6ff, #0072ff);
            -webkit-background-clip: text;
            -webkit-text-fill-color: transparent;
        }

        .subtitle {
            color: #a0a0ff;
            font-size: 1.1rem;
        }

        .chart-container {
            position: relative;
            width: 100%;
            height: 400px;
            margin-bottom: 20px;
        }

        canvas {
            border-radius: 10px;
            background: rgba(0, 0, 0, 0.2);
        }

        .chart-info {
            display: flex;
            justify-content: space-between;
            margin-top: 20px;
            padding: 15px;
            background: rgba(0, 0, 0, 0.2);
            border-radius: 10px;
        }

        .data-point {
            display: flex;
            align-items: center;
            gap: 10px;
        }

        .color-indicator {
            width: 15px;
            height: 15px;
            border-radius: 50%;
            background: linear-gradient(to right, #00c6ff, #0072ff);
        }

        .controls {
            display: flex;
            gap: 15px;
            margin-top: 20px;
        }

        button {
            flex: 1;
            padding: 12px;
            border: none;
            border-radius: 8px;
            background: linear-gradient(to right, #00c6ff, #0072ff);
            color: white;
            font-weight: bold;
            cursor: pointer;
            transition: transform 0.2s, box-shadow 0.2s;
        }

        button:hover {
            transform: translateY(-2px);
            box-shadow: 0 5px 15px rgba(0, 114, 255, 0.4);
        }

        .tooltip {
            position: absolute;
            padding: 8px 12px;
            background: rgba(0, 0, 0, 0.8);
            border-radius: 6px;
            pointer-events: none;
            opacity: 0;
            transition: opacity 0.3s;
            font-size: 0.9rem;
            z-index: 10;
        }

        @media (max-width: 768px) {
            .chart-info, .controls {
                flex-direction: column;
            }

            h1 {
                font-size: 2rem;
            }
        }
    </style>
</head>
<body>
    <div class="container">
        <header>
            <h1>历史数据曲线图</h1>
            <p class="subtitle">展示过去30天的数据变化趋势</p>
        </header>

        <div class="chart-container">
            <canvas id="historyChart"></canvas>
            <div class="tooltip" id="tooltip"></div>
        </div>

        <div class="chart-info">
            <div class="data-point">
                <div class="color-indicator"></div>
                <div>
                    <div>当前值</div>
                    <div id="currentValue">125.6</div>
                </div>
            </div>
            <div class="data-point">
                <div class="color-indicator" style="background: #ff6b6b;"></div>
                <div>
                    <div>最高值</div>
                    <div id="maxValue">142.3</div>
                </div>
            </div>
            <div class="data-point">
                <div class="color-indicator" style="background: #4cd964;"></div>
                <div>
                    <div>最低值</div>
                    <div id="minValue">98.7</div>
                </div>
            </div>
        </div>

        <div class="controls">
            <button id="btnWeek">最近7天</button>
            <button id="btnMonth">最近30天</button>
            <button id="btnQuarter">最近90天</button>
            <button id="btnRandom">生成随机数据</button>
        </div>
    </div>

    <script>
        // 获取Canvas元素和上下文
        const canvas = document.getElementById('historyChart');
        const ctx = canvas.getContext('2d');
        const tooltip = document.getElementById('tooltip');

        // 设置Canvas尺寸
        function resizeCanvas() {
            const container = canvas.parentElement;
            canvas.width = container.clientWidth;
            canvas.height = container.clientHeight;
            drawChart();
        }

        // 初始数据
        let dataPoints = generateRandomData(30, 80, 150);
        let daysToShow = 30;

        // 生成随机数据
        function generateRandomData(count, min, max) {
            const data = [];
            let lastValue = (min + max) / 2;

            for (let i = 0; i < count; i++) {
                // 随机波动,但保持在合理范围内
                const change = (Math.random() - 0.5) * 20;
                lastValue = Math.max(min, Math.min(max, lastValue + change));
                data.push(lastValue);
            }

            return data;
        }

        // 绘制图表
        function drawChart() {
            const width = canvas.width;
            const height = canvas.height;
            const padding = 50;

            // 清除画布
            ctx.clearRect(0, 0, width, height);

            // 绘制网格
            drawGrid(width, height, padding);

            // 绘制坐标轴
            drawAxes(width, height, padding);

            // 绘制曲线
            drawCurve(width, height, padding);

            // 更新信息显示
            updateInfo();
        }

        // 绘制网格
        function drawGrid(width, height, padding) {
            ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
            ctx.lineWidth = 1;

            // 水平网格线
            const horizontalLines = 5;
            for (let i = 0; i <= horizontalLines; i++) {
                const y = padding + (height - 2 * padding) * (i / horizontalLines);
                ctx.beginPath();
                ctx.moveTo(padding, y);
                ctx.lineTo(width - padding, y);
                ctx.stroke();

                // 添加Y轴标签
                const value = getMaxValue() - (getMaxValue() - getMinValue()) * (i / horizontalLines);
                ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
                ctx.font = '12px Arial';
                ctx.textAlign = 'right';
                ctx.fillText(value.toFixed(1), padding - 10, y + 4);
            }

            // 垂直网格线
            const dataToShow = dataPoints.slice(-daysToShow);
            const verticalLines = Math.min(daysToShow, 10);
            for (let i = 0; i <= verticalLines; i++) {
                const x = padding + (width - 2 * padding) * (i / verticalLines);
                ctx.beginPath();
                ctx.moveTo(x, padding);
                ctx.lineTo(x, height - padding);
                ctx.stroke();

                // 添加X轴标签
                const dayIndex = Math.floor((dataToShow.length - 1) * (i / verticalLines));
                ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
                ctx.font = '12px Arial';
                ctx.textAlign = 'center';
                ctx.fillText(`第 ${dayIndex + 1}天`, x, height - padding + 20);
            }
        }

        // 绘制坐标轴
        function drawAxes(width, height, padding) {
            ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
            ctx.lineWidth = 2;

            // X轴
            ctx.beginPath();
            ctx.moveTo(padding, height - padding);
            ctx.lineTo(width - padding, height - padding);
            ctx.stroke();

            // Y轴
            ctx.beginPath();
            ctx.moveTo(padding, padding);
            ctx.lineTo(padding, height - padding);
            ctx.stroke();
        }

        // 绘制曲线
        function drawCurve(width, height, padding) {
            const dataToShow = dataPoints.slice(-daysToShow);
            const maxValue = getMaxValue();
            const minValue = getMinValue();
            const valueRange = maxValue - minValue;

            // 创建渐变
            const gradient = ctx.createLinearGradient(0, padding, 0, height - padding);
            gradient.addColorStop(0, 'rgba(0, 198, 255, 0.8)');
            gradient.addColorStop(1, 'rgba(0, 114, 255, 0.3)');

            ctx.strokeStyle = gradient;
            ctx.fillStyle = gradient;
            ctx.lineWidth = 3;

            // 绘制曲线
            ctx.beginPath();
            for (let i = 0; i < dataToShow.length; i++) {
                const x = padding + (width - 2 * padding) * (i / (dataToShow.length - 1));
                const y = height - padding - ((dataToShow[i] - minValue) / valueRange) * (height - 2 * padding);

                if (i === 0) {
                    ctx.moveTo(x, y);
                } else {
                    // 使用二次贝塞尔曲线平滑连接点
                    const prevX = padding + (width - 2 * padding) * ((i - 1) / (dataToShow.length - 1));
                    const prevY = height - padding - ((dataToShow[i - 1] - minValue) / valueRange) * (height - 2 * padding);

                    const cpX = (prevX + x) / 2;
                    const cpY1 = prevY;
                    const cpY2 = y;

                    ctx.bezierCurveTo(cpX, cpY1, cpX, cpY2, x, y);
                }
            }
            ctx.stroke();

            // 填充曲线下方区域
            ctx.lineTo(width - padding, height - padding);
            ctx.lineTo(padding, height - padding);
            ctx.closePath();
            ctx.fill();

            // 绘制数据点
            ctx.fillStyle = '#ffffff';
            for (let i = 0; i < dataToShow.length; i++) {
                const x = padding + (width - 2 * padding) * (i / (dataToShow.length - 1));
                const y = height - padding - ((dataToShow[i] - minValue) / valueRange) * (height - 2 * padding);

                ctx.beginPath();
                ctx.arc(x, y, 4, 0, Math.PI * 2);
                ctx.fill();
            }
        }

        // 获取数据最大值
        function getMaxValue() {
            const dataToShow = dataPoints.slice(-daysToShow);
            return Math.max(...dataToShow) * 1.05; // 增加5%的顶部空间
        }

        // 获取数据最小值
        function getMinValue() {
            const dataToShow = dataPoints.slice(-daysToShow);
            return Math.min(...dataToShow) * 0.95; // 增加5%的底部空间
        }

        // 更新信息显示
        function updateInfo() {
            const dataToShow = dataPoints.slice(-daysToShow);
            const currentValue = dataToShow[dataToShow.length - 1];
            const maxValue = Math.max(...dataToShow);
            const minValue = Math.min(...dataToShow);

            document.getElementById('currentValue').textContent = currentValue.toFixed(1);
            document.getElementById('maxValue').textContent = maxValue.toFixed(1);
            document.getElementById('minValue').textContent = minValue.toFixed(1);
        }

        // 处理鼠标移动事件
        function handleMouseMove(e) {
            const rect = canvas.getBoundingClientRect();
            const mouseX = e.clientX - rect.left;
            const mouseY = e.clientY - rect.top;

            const width = canvas.width;
            const height = canvas.height;
            const padding = 50;

            const dataToShow = dataPoints.slice(-daysToShow);
            const maxValue = getMaxValue();
            const minValue = getMinValue();
            const valueRange = maxValue - minValue;

            // 检查鼠标是否在图表区域内
            if (mouseX >= padding && mouseX <= width - padding &&
                mouseY >= padding && mouseY <= height - padding) {

                // 计算最近的数据点
                const dataIndex = Math.round(((mouseX - padding) / (width - 2 * padding)) * (dataToShow.length - 1));
                const value = dataToShow[dataIndex];
                const x = padding + (width - 2 * padding) * (dataIndex / (dataToShow.length - 1));
                const y = height - padding - ((value - minValue) / valueRange) * (height - 2 * padding);

                // 显示工具提示
                tooltip.style.opacity = '1';
                tooltip.style.left = `${x + 10}px`;
                tooltip.style.top = `${y - 10}px`;
                tooltip.innerHTML = `第 ${dataIndex + 1}天<br>数值: ${value.toFixed(1)}`;

                // 高亮当前数据点
                ctx.clearRect(0, 0, width, height);
                drawChart();

                ctx.fillStyle = '#ffeb3b';
                ctx.beginPath();
                ctx.arc(x, y, 6, 0, Math.PI * 2);
                ctx.fill();

                ctx.strokeStyle = '#ffeb3b';
                ctx.lineWidth = 1;
                ctx.beginPath();
                ctx.moveTo(x, padding);
                ctx.lineTo(x, height - padding);
                ctx.stroke();

                ctx.beginPath();
                ctx.moveTo(padding, y);
                ctx.lineTo(width - padding, y);
                ctx.stroke();
            } else {
                tooltip.style.opacity = '0';
            }
        }

        // 初始化
        window.addEventListener('resize', resizeCanvas);
        canvas.addEventListener('mousemove', handleMouseMove);

        // 按钮事件
        document.getElementById('btnWeek').addEventListener('click', () => {
            daysToShow = 7;
            drawChart();
        });

        document.getElementById('btnMonth').addEventListener('click', () => {
            daysToShow = 30;
            drawChart();
        });

        document.getElementById('btnQuarter').addEventListener('click', () => {
            daysToShow = 90;
            dataPoints = generateRandomData(90, 80, 150);
            drawChart();
        });

        document.getElementById('btnRandom').addEventListener('click', () => {
            dataPoints = generateRandomData(daysToShow, 80, 150);
            drawChart();
        });

        // 初始绘制
        resizeCanvas();
    </script>
</body>
</html>

HTML

  • container:主功能容器:尺寸:最大宽度 1000px,100% 自适应宽度;视觉:玻璃拟物风格(background: rgba(255,255,255,0.1)+backdrop-filter: blur(10px))、圆角 20px、阴影(0 15px 35px rgba(0,0,0,0.2)),与背景形成层次;内边距:padding: 30px,隔离内部组件。
  • header:头部信息区:显示图表标题与副标题,明确工具用途(“历史数据曲线图”“过去 30 天数据趋势”)
  • h1:图表主标题:文字渐变效果(linear-gradient(to right, #00c6ff, #0072ff)),增强视觉吸引力,突出主题
  • subtitle:副标题:浅紫灰色(#a0a0ff),补充说明图表内容,降低用户理解成本
  • chart-container:图表核心容器: 定位:position: relative,为绝对定位的 Canvas 和 tooltip 提供基准;尺寸:宽 100%、高 400px,确保图表有足够展示空间; 包含 Canvas(图表载体)和 tooltip(鼠标悬浮提示)。
  • canvas id="historyChart:图表绘制载体:基于 Canvas API 实现网格、坐标轴、曲线、数据点的绘制,是工具的技术核心
  • tooltip:鼠标悬浮提示:默认隐藏(opacity: 0),鼠标移动到数据点时显示,包含 “天数 + 数值” 信息,提升交互体验
  • chart-info:关键指标区:布局:flex 横向排列,显示 “当前值”“最高值”“最低值” 三个核心指标;视觉:浅黑背景(rgba(0,0,0,0.2))、圆角 10px、内边距 15px,与图表区区分,突出数据重点。
  • data-point:指标项: 布局:flex 对齐,包含 “颜色指示器” 和 “指标文本”;颜色区分:当前值(蓝渐变)、最高值(红#ff6b6b)、最低值(绿#4cd964),通过颜色暗示指标属性(红 = 高、绿 = 低)。
  • color-indicator:颜色指示器:15×15px 圆形,通过背景色区分指标类型,视觉上快速关联图表曲线
  • controls:控制按钮容器:flex 横向排列四个功能按钮,gap: 15px 确保间距,方便用户切换数据范围
  • button:功能按钮: 布局:flex: 1 平均分配宽度,适配不同屏幕;视觉:蓝渐变背景、白色文字、加粗字体、圆角 8px;功能:分别对应 “最近 7 天”“最近 30 天”“最近 90 天”“生成随机数据”,实现数据切换与重置。

CSS

  • .container 主容器:玻璃拟物风格(backdrop-filter: blur(10px))+ 阴影,增强层次感;圆角 20px,柔化边缘,避免生硬。
  • h1 标题文字: 渐变:background: linear-gradient(to right, #00c6ff, #0072ff); 文字裁剪:-webkit-background-clip: text+-webkit-text-fill-color: transparent,实现文字渐变效果,视觉突出。
  • .subtitle 副标题:浅紫灰(#a0a0ff)、1.1rem 字体,作为标题的辅助说明,视觉上不抢焦点。
  • .chart-container 图表容器:position: relative,确保 Canvas 和 tooltip 定位正确;高度 400px,为图表提供足够纵向空间。
  • canvas 画布:圆角 10px、浅黑背景(rgba(0,0,0,0.2)),与主容器背景区分,突出图表内容。
  • .tooltip 悬浮提示:定位:absolute,pointer-events: none(避免遮挡鼠标事件);视觉:深黑背景(rgba(0,0,0,0.8))、圆角 6px、内边距 8px 12px、白色文字、0.9rem 字体;过渡:transition: opacity 0.3s,显示 / 隐藏时平滑过渡,避免突兀。
  • .chart-info 指标区:浅黑背景(rgba(0,0,0,0.2))、圆角 10px、内边距 15px,flex 布局使三个指标横向均匀分布,信息清晰。
  • .data-point 指标项:display: flex+align-items: center+gap: 10px,颜色指示器与文字对齐,视觉连贯;文字区上下分布(指标名 + 数值),层级分明。
  • .color-indicator 颜色指示器:15×15px 圆形(border-radius: 50%),通过背景色区分指标(当前值蓝渐变、最高值红、最低值绿),快速建立视觉关联。
  • .controls 按钮容器:display: flex+gap: 15px,按钮横向排列且间距均匀;margin-top: 20px,与指标区分隔,布局整齐。
  • button 功能按钮:基础样式:padding: 12px、无边框、圆角 8px、蓝渐变背景(linear-gradient(to right, #00c6ff, #0072ff))、白色加粗文字;交互:transition: transform 0.2s, box-shadow 0.2s,hover 时上移 2px(translateY(-2px))+ 蓝阴影(0 5px 15px rgba(0,114,255,0.4)),模拟 “浮起” 效果,反馈明确。

JavaScript

JS 核心目标是“实现数据可视化与交互”,通过Canvas 绘制+数据生成+事件监听,覆盖“图表渲染-数据切换-交互反馈”全流程,逻辑清晰且性能优化到位:

1. 初始化与核心变量

// 1. 获取Canvas与上下文(启用频繁读取优化)
const canvas = document.getElementById('historyChart');
const ctx = canvas.getContext('2d');
const tooltip = document.getElementById('tooltip');

// 2. 状态变量:数据与显示配置
let dataPoints = generateRandomData(30, 80, 150); // 初始30天随机数据(80-150范围)
let daysToShow = 30; // 默认显示30天数据

2. Canvas 尺寸适配(resizeCanvas

确保 Canvas 尺寸随容器变化,避免窗口缩放导致图表变形:

function resizeCanvas() {
    const container = canvas.parentElement;
    canvas.width = container.clientWidth; // 宽度适配容器
    canvas.height = container.clientHeight; // 高度固定(400px)
    drawChart(); // 尺寸变化后重新绘制图表
}

// 窗口resize时触发适配,初始加载时执行一次
window.addEventListener('resize', resizeCanvas);
resizeCanvas();

3. 随机数据生成(generateRandomData

生成有“波动合理性”的随机数据(非完全随机),模拟真实数据趋势:

function generateRandomData(count, min, max) {
    const data = [];
    let lastValue = (min + max) / 2; // 初始值设为范围中间值,避免极端值

    for (let i = 0; i < count; i++) {
        const change = (Math.random() - 0.5) * 20; // 随机波动(-10~+10)
        // 限制值在[min, max]范围内,避免数据异常
        lastValue = Math.max(min, Math.min(max, lastValue + change));
        data.push(lastValue);
    }
    return data;
}

4. 图表核心绘制(drawChart

统筹图表绘制流程,调用网格、坐标轴、曲线的绘制函数,是视觉呈现的核心:

function drawChart() {
    const width = canvas.width;
    const height = canvas.height;
    const padding = 50; // 图表内边距(预留轴标签空间)

    ctx.clearRect(0, 0, width, height); // 清除画布,避免重绘重叠
    drawGrid(width, height, padding); // 绘制网格与轴标签
    drawAxes(width, height, padding); // 绘制坐标轴
    drawCurve(width, height, padding); // 绘制曲线与数据点
    updateInfo(); // 更新关键指标(当前/最高/最低值)
}

(1)绘制网格与轴标签(drawGrid

function drawGrid(width, height, padding) {
    ctx.strokeStyle = 'rgba(255,255,255,0.1)'; // 浅白网格线(半透明)
    ctx.lineWidth = 1;

    // 1. 水平网格线(Y轴方向,显示数值标签)
    const horizontalLines = 5; // 5条水平线(含上下边界)
    for (let i = 0; i <= horizontalLines; i++) {
        const y = padding + (height - 2 * padding) * (i / horizontalLines); // 计算Y坐标
        // 绘制水平线
        ctx.beginPath();
        ctx.moveTo(padding, y);
        ctx.lineTo(width - padding, y);
        ctx.stroke();

        // 绘制Y轴数值标签(从高到低)
        const value = getMaxValue() - (getMaxValue() - getMinValue()) * (i / horizontalLines);
        ctx.fillStyle = 'rgba(255,255,255,0.7)';
        ctx.font = '12px Arial';
        ctx.textAlign = 'right';
        ctx.fillText(value.toFixed(1), padding - 10, y + 4); // 右对齐,避免遮挡
    }

    // 2. 垂直网格线(X轴方向,显示天数标签)
    const dataToShow = dataPoints.slice(-daysToShow); // 截取当前要显示的数据
    const verticalLines = Math.min(daysToShow, 10); // 最多10条垂直线,避免拥挤
    for (let i = 0; i <= verticalLines; i++) {
        const x = padding + (width - 2 * padding) * (i / verticalLines); // 计算X坐标
        // 绘制垂直线
        ctx.beginPath();
        ctx.moveTo(x, padding);
        ctx.lineTo(x, height - padding);
        ctx.stroke();

        // 绘制X轴天数标签
        const dayIndex = Math.floor((dataToShow.length - 1) * (i / verticalLines));
        ctx.fillStyle = 'rgba(255,255,255,0.7)';
        ctx.font = '12px Arial';
        ctx.textAlign = 'center';
        ctx.fillText(`第 ${dayIndex + 1}天`, x, height - padding + 20); // 位于轴下方
    }
}

(2)绘制坐标轴(drawAxes

function drawAxes(width, height, padding) {
    ctx.strokeStyle = 'rgba(255,255,255,0.5)'; // 半透明白色坐标轴(比网格线粗)
    ctx.lineWidth = 2;

    // X轴(水平轴,底部)
    ctx.beginPath();
    ctx.moveTo(padding, height - padding);
    ctx.lineTo(width - padding, height - padding);
    ctx.stroke();

    // Y轴(垂直轴,左侧)
    ctx.beginPath();
    ctx.moveTo(padding, padding);
    ctx.lineTo(padding, height - padding);
    ctx.stroke();
}

(3)绘制曲线与数据点(drawCurve

function drawCurve(width, height, padding) {
    const dataToShow = dataPoints.slice(-daysToShow);
    const maxValue = getMaxValue();
    const minValue = getMinValue();
    const valueRange = maxValue - minValue; // 数据范围(用于映射到Canvas高度)

    // 1. 创建曲线渐变(从深蓝到浅蓝)
    const gradient = ctx.createLinearGradient(0, padding, 0, height - padding);
    gradient.addColorStop(0, 'rgba(0, 198, 255, 0.8)'); // 顶部深
    gradient.addColorStop(1, 'rgba(0, 114, 255, 0.3)'); // 底部浅

    // 2. 绘制平滑曲线(二次贝塞尔曲线)
    ctx.strokeStyle = gradient;
    ctx.fillStyle = gradient;
    ctx.lineWidth = 3;

    ctx.beginPath();
    for (let i = 0; i < dataToShow.length; i++) {
        // 计算当前点的Canvas坐标(数据值映射到画布像素)
        const x = padding + (width - 2 * padding) * (i / (dataToShow.length - 1));
        const y = height - padding - ((dataToShow[i] - minValue) / valueRange) * (height - 2 * padding);

        if (i === 0) {
            ctx.moveTo(x, y); // 第一个点:移动到起点
        } else {
            // 后续点:用二次贝塞尔曲线连接,实现平滑效果
            const prevX = padding + (width - 2 * padding) * ((i - 1) / (dataToShow.length - 1));
            const prevY = height - padding - ((dataToShow[i - 1] - minValue) / valueRange) * (height - 2 * padding);

            const cpX = (prevX + x) / 2; // 控制点X(两点中间)
            const cpY1 = prevY; // 控制点Y1(前一点Y)
            const cpY2 = y; // 控制点Y2(当前点Y)

            ctx.bezierCurveTo(cpX, cpY1, cpX, cpY2, x, y);
        }
    }
    ctx.stroke(); // 绘制曲线

    // 3. 填充曲线下方区域(增强视觉层次)
    ctx.lineTo(width - padding, height - padding); // 右下角
    ctx.lineTo(padding, height - padding); // 左下角
    ctx.closePath();
    ctx.fill(); // 填充渐变

    // 4. 绘制数据点(白色圆点,突出数据位置)
    ctx.fillStyle = '#ffffff';
    for (let i = 0; i < dataToShow.length; i++) {
        const x = padding + (width - 2 * padding) * (i / (dataToShow.length - 1));
        const y = height - padding - ((dataToShow[i] - minValue) / valueRange) * (height - 2 * padding);

        ctx.beginPath();
        ctx.arc(x, y, 4, 0, Math.PI * 2); // 4px半径圆形
        ctx.fill();
    }
}

5. 交互逻辑(鼠标移动+按钮切换)

(1)鼠标移动提示(handleMouseMove

function handleMouseMove(e) {
    const rect = canvas.getBoundingClientRect(); // 获取Canvas在页面中的位置
    const mouseX = e.clientX - rect.left; // 鼠标在Canvas内的X坐标
    const mouseY = e.clientY - rect.top; // 鼠标在Canvas内的Y坐标

    const width = canvas.width;
    const height = canvas.height;
    const padding = 50;
    const dataToShow = dataPoints.slice(-daysToShow);
    const maxValue = getMaxValue();
    const minValue = getMinValue();

    // 检查鼠标是否在图表有效区域内(排除边距)
    if (mouseX >= padding && mouseX <= width - padding && mouseY >= padding && mouseY <= height - padding) {
        // 计算最近的数据点索引
        const dataIndex = Math.round(((mouseX - padding) / (width - 2 * padding)) * (dataToShow.length - 1));
        const value = dataToShow[dataIndex];
        // 计算该数据点的Canvas坐标
        const x = padding + (width - 2 * padding) * (dataIndex / (dataToShow.length - 1));
        const y = height - padding - ((value - minValue) / (maxValue - minValue)) * (height - 2 * padding);

        // 1. 显示tooltip(天数+数值)
        tooltip.style.opacity = '1';
        tooltip.style.left = `${x + 10}px`; // 右移10px,避免遮挡
        tooltip.style.top = `${y - 10}px`; // 上移10px,避免遮挡
        tooltip.innerHTML = `第 ${dataIndex + 1}天<br>数值: ${value.toFixed(1)}`;

        // 2. 高亮数据点(黄色圆点+十字线)
        ctx.clearRect(0, 0, width, height); // 清除画布
        drawChart(); // 重新绘制基础图表

        // 绘制高亮圆点(黄色,6px半径)
        ctx.fillStyle = '#ffeb3b';
        ctx.beginPath();
        ctx.arc(x, y, 6, 0, Math.PI * 2);
        ctx.fill();

        // 绘制十字线(黄色,贯穿轴)
        ctx.strokeStyle = '#ffeb3b';
        ctx.lineWidth = 1;
        // 竖线(垂直贯穿)
        ctx.beginPath();
        ctx.moveTo(x, padding);
        ctx.lineTo(x, height - padding);
        ctx.stroke();
        // 横线(水平贯穿)
        ctx.beginPath();
        ctx.moveTo(padding, y);
        ctx.lineTo(width - padding, y);
        ctx.stroke();
    } else {
        // 鼠标离开有效区域,隐藏tooltip
        tooltip.style.opacity = '0';
    }
}

// 绑定鼠标移动事件
canvas.addEventListener('mousemove', handleMouseMove);

(2)按钮切换数据范围

// 最近7天
document.getElementById('btnWeek').addEventListener('click', () => {
    daysToShow = 7;
    drawChart(); // 重新绘制图表
});

// 最近30天
document.getElementById('btnMonth').addEventListener('click', () => {
    daysToShow = 30;
    drawChart();
});

// 最近90天(需生成90天数据)
document.getElementById('btnQuarter').addEventListener('click', () => {
    daysToShow = 90;
    dataPoints = generateRandomData(90, 80, 150); // 生成90天数据
    drawChart();
});

// 生成随机数据(重置当前时间范围的数据)
document.getElementById('btnRandom').addEventListener('click', () => {
    dataPoints = generateRandomData(daysToShow, 80, 150);
    drawChart();
});

6. 辅助函数

// 获取当前显示数据的最大值(增加5%顶部空间,避免曲线顶到边界)
function getMaxValue() {
    const dataToShow = dataPoints.slice(-daysToShow);
    return Math.max(...dataToShow) * 1.05;
}

// 获取当前显示数据的最小值(增加5%底部空间,避免曲线贴到底部)
function getMinValue() {
    const dataToShow = dataPoints.slice(-daysToShow);
    return Math.min(...dataToShow) * 0.95;
}

// 更新关键指标显示(当前值、最高值、最低值)
function updateInfo() {
    const dataToShow = dataPoints.slice(-daysToShow);
    const currentValue = dataToShow[dataToShow.length - 1]; // 最后一个数据点(当前值)
    const maxValue = Math.max(...dataToShow);
    const minValue = Math.min(...dataToShow);

    // 更新DOM文本
    document.getElementById('currentValue').textContent = currentValue.toFixed(1);
    document.getElementById('maxValue').textContent = maxValue.toFixed(1);
    document.getElementById('minValue').textContent = minValue.toFixed(1);
}

各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!