初识 canvas

什么是 canvas ?

  1. canvasHTML5 新增的元素,可通过使用 JavaScript 中的脚本来绘制图形;
  2. 可以使用 canvas 标签来定义一个 canvas 元素;
    1. 使用 canvas 标签时,建议要成对出现,不要使用闭合的形式;
    2. canvas 元素具有默认高宽,width:300px,height:150px
  3. 替换内容
    1. 某些较老的浏览器(尤其是 IE9 之前的 IE 浏览器)不支持 HTML 元素 canvas,要给用户展示些替代内容,这非常简单:只需要在 canvas 标签中提供替换内容就可以;
    2. 支持 canvas 的浏览器将会忽略在容器中包含的内容,并且只是正常渲染 canvas ,不支持 canvas 的浏览器会显示代替内容;
  4. canvas 标签的两个属性:widthheight,当没有设置宽度和高度的时候,canvas 会初始化宽度为 300 像素、高度为 150 像素;
    1. html 属性:设置 widthheight 时只影响画布本身不影响画布内容;
    2. css 属性:设置 widthheight 时不但会影响画布本身的高宽,还会使画布中的内容等比例缩放 (缩放参照于画布默认的尺寸);

渲染上下文

  1. canvas 元素只是创造了一个固定大小的画布,要想在它上面去绘制内容,需要找到它的渲染上下文;

  2. canvas 元素有一个叫做 getContext() 的方法,是用来获得渲染上下文和它的绘画功能,getContext() 只有一个参数,上下文的格式;

使用 canvas 绘制图片或者是文字在 Retina 屏中会非常模糊

  1. 模糊的原因:

    1. Retina 屏中 Canvas 绘制模糊的核心原因是 像素密度不匹配
    2. Retina 屏的物理像素是 CSS 像素的 2(部分设备 3 倍),而 Canvas 默认按 CSS 像素渲染,导致物理像素被拉伸,出现锯齿或模糊;
  2. 解决方案:根据设备像素比放大 Canvas 的实际尺寸 (宽高属性),再用 CSS 缩回到目标显示尺寸,让 Canvas 的物理像素与屏幕物理像素完全匹配;

    1. 示例代码
      <canvas id="canvas" width="400" height="150">您的浏览器当前不支持 canvas</canvas>
      <script>
          const canvas = document.getElementById('canvas');
          const ctx = canvas.getContext('2d');
      
          // 1.获取屏幕像素比
          const ratio = window.devicePixelRatio || 1;
          const oldWidth = canvas.width;
          const oldHeight = canvas.height;
      
          // 2.将 canvas 放大到该设备像素比来绘制
          canvas.width = canvas.width * ratio;
          canvas.height = canvas.height * ratio;
      
          // 3.然后将 canvas 的内容压缩到一倍来展示
          canvas.style.width = oldWidth + 'px';
          canvas.style.height = oldHeight + 'px';
      
          // 4.设置 canvas图像 x,y 轴的缩放比例
          ctx.scale(ratio, ratio);
      
          ctx.fillStyle = "grey";
          ctx.font = "40px sans-serif";
          ctx.fillText("像素清晰度对比", 70, 100);
      </script>
      
    2. 前后对比效果展示

canvas 绘制矩形、路径、曲线、文本、阴影

  1. canvas 只支持一种原生的图形绘制,原生只支持矩形绘制;

  2. 所有其他的图形的绘制都至少需要生成一条路径;

绘制矩形

  1. API 和属性

    API 或 属性 描述
    fillRect(x, y, width, height) 绘制一个填充的矩形
    · x、y 指定了绘制的矩形的左上角的坐标
    · width、height 设置矩形的尺寸 (存在边框的话,边框会在 widthheight 上占据一个边框的宽度)
    strokeRect(x, y, width, height) 绘制一个矩形的边框
    · x、y 指定了绘制的矩形的左上角的坐标
    · width、height 设置矩形的尺寸 (存在边框的话,边框会在 widthheight 上占据一个边框的宽度)
    clearRect(x, y, width, height) 清除指定矩形区域
    · x、y 指定了绘制的矩形的左上角的坐标
    · width、height 设置矩形的尺寸 (存在边框的话,边框会在 widthheight 上占据一个边框的宽度)
    fillStyle 设置图形的填充颜色 (默认黑色)
    strokeStyle 设置图形轮廓的颜色 (默认黑色)
    lineWidth 设置当前绘线的粗细,属性值必须为正数 (默认值是 1.0,为 0负数InfinityNaN 会被忽略)
    lineJoin 设定线条与线条间接合处的样式 round: 圆角、bevel: 斜角、miter: 直角(默认)
  2. 案例

    <canvas id="canvas" width="100%" style="height: 100%"></canvas>
    <script>
        var canvas = document.getElementById("canvas");
        var ctx = canvas.getContext("2d");
    
        var height = 0;
        var timer = setInterval(function () {
            ctx.clearRect(0, 0, 400, 300);
    
            // ctx.fillStyle = "deeppink"; // 设置图形的填充颜色
            ctx.strokeStyle = "pink"; // 设置图形轮廓的颜色
            ctx.lineWidth = 5; // 设置当前绘线的粗细
            ctx.lineJoin = "round"; // 设定线条与线条间接合处的样式:圆角、斜角、直角
            ctx.strokeRect(100, height, 50, 50);
    
            height += 2;
            if (height > 300) height = 0;
        }, 1000 / 60);
    </script>
    
  3. 效果展示

绘制路径

  1. 绘制步骤

    1. 图形的基本元素是路径,路径是通过不同颜色和宽度的线段或曲线相连形成的不同形状的点的集合;
      1. 首先需要创建路径起始点;
      2. 然后使用画图命令去画出路径;
      3. 之后把路径封闭;
    2. 一旦路径生成,就能通过描边或填充路径区域来渲染图形;
  2. API 和属性

    API 或 属性 描述
    beginPath() 开始一条路径,或重置当前的路径
    moveTo(x, y) 把路径移动到画布中的指定点,不创建线条
    lineTo(x, y) 添加一个新点,然后创建从该点到画布中最后指定点的线条
    closePath() 不是必需的,这个方法会通过绘制一条从当前点到开始点的直线来闭合图形
    stroke() 通过线条来绘制图形轮廓
    fill() 通过填充路径的内容区域生成实心的图形,自动调用 closePath()
    rect(x, y, width, height) 绘制一个坐标为 (x, y),宽高为 width、height 的矩形
    lineCap 指定绘制每一条线段末端的属性
    · butt:线段末端以矩形结束(默认值)
    · round:线段末端以圆形结束
    · square:线段末端以矩形结束,线段两端长度增加为线段厚度一半的矩形区域
    save() 将当前状态放入栈中,保存到栈中的绘制状态
    · 当前的变换矩阵
    · 当前的剪切区域(基本用不到)
    · 当前的虚线列表(基本用不到)
    · 以下属性当前的值:strokeStylefillStylelineWidthlineCaplineJoin
    restore() 在绘图状态栈中弹出顶端的状态,将 canvas 恢复到最近的保存状态的方法
  3. 其他概念

    1. 路径容器:每次调用路径 api 时,都会向路径容器里做登记,调用 beginPath 时,清空整个 路径容器
    2. 样式容器:每次调用样式 api 时,都会往样式容器里做登记,调用 save 时候,将样式容器里的状态压入样式栈,调用 restore 时候,将样式栈的栈顶状态弹出到样式样式容器里,进行覆盖;
    3. 样式栈:调用 save 时候,将样式容器里的状态压入样式栈,调用 restore 时候,将样式栈的栈顶状态弹出到样式样式容器里,进行覆盖;

案例一

  1. 示例代码

    <canvas id="canvas"></canvas>
    <script>
        window.onload = function () {
            var canvas = document.querySelector("#canvas");
            if (canvas.getContext) {
                var ctx = canvas.getContext("2d");
    
                ctx.save();
    
                ctx.fillStyle = "pink";
                ctx.save();
    
                ctx.fillStyle = "deeppink";
                ctx.fillStyle = "blue";
                ctx.save();
    
                ctx.fillStyle = "red";
                ctx.save();
    
                ctx.fillStyle = "green";
                ctx.save();
    
                ctx.beginPath();
                // 出栈两次 为红色
                ctx.restore();
                ctx.restore();
                ctx.fillRect(50, 50, 100, 100);
            }
        }
    </script>
    
  2. 效果展示

案例二

  1. 示例代码

    <canvas id="canvas"></canvas>
    <script>
        window.onload = function () {
            var canvas = document.querySelector("#canvas");
            const getPixelRatio = (context) => window.devicePixelRatio || 1;
            const ratio = getPixelRatio();
            canvas.style.width = document.documentElement.clientWidth + 'px';
            canvas.style.height = 250 + 'px';
            canvas.width = document.documentElement.clientWidth * ratio;
            canvas.height = 250 * ratio;
    
            if (canvas.getContext) {
                var ctx = canvas.getContext("2d");
                ctx.strokeStyle = "deeppink";
                ctx.fillStyle = "green";
                ctx.lineWidth = 10;
    
                ctx.moveTo(100, 100);
                ctx.lineTo(100, 200);
                ctx.lineTo(200, 200);
                ctx.lineTo(100, 100);
                ctx.closePath();
                ctx.stroke();
    
                ctx.moveTo(200, 200);
                ctx.lineTo(200, 300);
                ctx.lineTo(300, 300);
                // fill方法会自动合并路径
                ctx.fill();
    
    
                ctx.beginPath();
    
    
                // 设置原点的坐标
                var originX = canvas.width / 2;
                var originY = canvas.height / 2;
    
                ctx.moveTo(100 + originX, 100);
                ctx.lineTo(100 + originX, 200);
                ctx.lineTo(200 + originX, 200);
                ctx.lineTo(100 + originX, 100);
                ctx.closePath();
                ctx.stroke();
    
                // 重置当前的路径
                ctx.moveTo(200 + originX, 200);
                ctx.lineTo(200 + originX, 300);
                ctx.lineTo(300 + originX, 300);
                // fill方法会自动合并路径
                ctx.fill();
            }
        }
    </script>
    
  2. 效果展示

绘制曲线

  1. API 和属性

    API 或 属性 描述
    arc(x, y, radius, startAngle, endAngle, anticlockwise) 绘制圆形
    · xy 为圆心坐标
    · radius 为半径
    · startAngleendAngle 参数用弧度定义了开始以及结束的弧度,这些都是以 x 轴为基准
    · anticlockwise 为一个布尔值,为 true 时,是逆时针方向,否则顺时针方向
    arcTo(x1, y1, x2, y2, radius) 根据给定的控制点和半径 radius 画一段圆弧,肯定会从 (x1, y1) ,但不一定经过 (x2, y2)(x2, y2) 只是控制一个方向
    quadraticCurveTo(cp1x, cp1y, x, y) 绘制二次贝塞尔曲线,cp1xcp1y 为一个控制点,xy 为结束点,起始点为 moveto 时指定的点
    bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) 绘制三次贝塞尔曲线,cp1xcp1y 为控制点一,cp2xcp2y 为控制点二,xy 为结束点,起始点为 moveto 时指定的点
  2. 坐标系

案例:绘制曲线

  1. 示例代码:

    <canvas id="canvas"></canvas>
    <script>
        window.onload = function () {
            var canvas = document.querySelector("#canvas");
            const getPixelRatio = (context) => {
                return window.devicePixelRatio || 1;
            }
            const ratio = getPixelRatio();
            canvas.style.width = document.documentElement.clientWidth + 'px';
            canvas.style.height = 250 + 'px';
            canvas.width = document.documentElement.clientWidth * ratio;
            canvas.height = 250 * ratio;
    
            if (canvas.getContext) {
                var ctx = canvas.getContext("2d");
                ctx.beginPath();
                ctx.moveTo(50, 50);
                ctx.lineTo(300, 0);
                ctx.lineTo(200, 200);
                ctx.stroke();
    
                ctx.beginPath();
                ctx.moveTo(50, 50)
                //以(300,0)、(200,200)为控制点,绘制一个半径是50的一段圆弧
                ctx.arcTo(300, 0, 200, 200, 50);
                ctx.stroke();
    
    
                // 设置原点的坐标
                var originX = canvas.width / 3;
                ctx.beginPath();
                ctx.moveTo(50 + originX, 50);
                ctx.lineTo(300 + originX, 0);
                ctx.lineTo(200 + originX, 200);
                ctx.stroke();
    
                ctx.beginPath();
                ctx.moveTo(50 + originX, 50)
                //以(300,0)、(200,200)绘制二次贝塞尔曲线
                ctx.quadraticCurveTo(300 + originX, 0, 200 + originX, 200);
                ctx.stroke();
    
    
                var originX = canvas.width / 3 * 2;
                ctx.beginPath();
                ctx.moveTo(50 + originX, 50);
                ctx.lineTo(300 + originX, 0);
                ctx.lineTo(0 + originX, 300);
                ctx.lineTo(300 + originX, 300);
                ctx.stroke();
    
                ctx.beginPath();
                ctx.moveTo(50 + originX, 50)
                //以(300,0)、(0,300)、(300,300)绘制三次贝塞尔曲线
                ctx.bezierCurveTo(300 + originX, 0, 0 + originX, 300, 300 + originX, 300);
                ctx.stroke();
            }
        }
    </script>
    
  2. 效果展示:

绘制文字

  1. API 和属性

    API 或 属性 描述
    fillText(text, x, y) 在指定的 (x, y) 位置填充指定的文本
    strokeText(text, x, y) 在指定的 (x, y) 位置绘制文本边框
    measureText(text) 返回一个 TextMetrics 对象,包含关于文本尺寸的信息
    font 在指定时,必须要有大小和字体缺一不可,默认的字体是 font=“10px sans-serif”
    textAlign 文本对齐方式
    · 文本左对齐
    · 文本右对齐
    · 文本居中对齐,居中是基于在 fillText 的时候所给的 x 值,也就是说文本一半在 x 左边,一半在 x 右边
    textBaseline 文本基线
    · 文本基线在文本块的顶部
    · 文本基线在文本块的中间
    · 文本基线在文本块的底部
  2. 绘制阴影

    属性 描述
    shadowOffsetX 阴影在 X 轴的延伸距离,默认为 0
    shadowOffsetY 阴影在 Y 轴的延伸距离,默认为 0
    shadowBlur 设定阴影的模糊程度,其数值并不跟像素数量挂钩,默认为 0
    shadowColor 设定阴影颜色效果,默认是全透明的黑色

案例

  1. 示例代码

    <canvas id="canvas" width="600px" style="height: 100%;"></canvas>
    <script>
    window.onload = function () {
        var canvas = document.querySelector("#canvas");
        const getPixelRatio = (context) => window.devicePixelRatio || 1;
        const ratio = getPixelRatio();
        canvas.style.width = document.documentElement.clientWidth - 20 + 'px';
        canvas.style.height = 250 + 'px';
        canvas.width = (document.documentElement.clientWidth - 20) * ratio;
        canvas.height = 250 * ratio;
        
        if (canvas.getContext) {
            var ctx = canvas.getContext("2d");
    
            ctx.fillStyle = "green";
            ctx.font = "40px sans-serif";
            ctx.fillText("你好", 100, 100);
    
            // 文本阴影 & 盒阴影
            ctx.fillStyle = 'green';
            ctx.shadowOffsetX = 5;
            ctx.shadowOffsetY = 5;
            ctx.shadowColor = '#333';
            ctx.shadowBlur = 10;
            ctx.fillRect(300, 50, 100, 80);
        }
    }
    </script>
    
  2. 效果展示

canvas 图形转换、渐变、合成

图形转换

  1. API

    API 描述
    translate(x, y) 移动 canvas 的原点到一个不同的位置
    · x 是左右偏移量,y 是上下偏移量
    · 在 canvastranslate 是累加的
    rotate(angle) 旋转的角度(angle),它是顺时针方向的,以弧度为单位的值
    · 旋转的中心点始终是 canvas 的原点,如果要改变它,需要用到 translate 方法
    · 在 canvasrotate 是累加的
    scale(x, y) canvas 中的像素数目,对形状、位图进行缩小或者放大
    · xy 是横轴和纵轴的缩放因子,必须是正值,值比 1.0 小则缩小,比 1.0 大则放大,值为 1.0 时什么效果都没有
    · 在 canvasscale 是累乘的

案例:移动

  1. 示例代码

    <canvas id="canvas" width="600px" height="250px"></canvas>
    <script>
    window.onload = function () {
        var canvas = document.querySelector("#canvas");
        if (canvas.getContext) {
            var ctx = canvas.getContext("2d");
            ctx.beginPath();
            ctx.fillRect(0, 0, 50, 50);
    
            // 在canvas中translate是累加的:向右偏移100、向下偏移100
            ctx.translate(50, 50);
            ctx.translate(50, 50);
            // 与上面的代码的等价
            // ctx.translate(100,100)
    
            ctx.beginPath();
            ctx.fillRect(0, 0, 50, 50);
        }
    }
    </script>
    
  2. 效果展示

案例:旋转

  1. 示例代码

    <canvas id="canvas" width="600px" height="250px"></canvas>
    <script>
    window.onload = function () {
        var canvas = document.querySelector("#canvas");
        if (canvas.getContext) {
            var ctx = canvas.getContext("2d");
            ctx.beginPath();
            ctx.fillStyle = '#eee';
            ctx.fillRect(0, 0, 200, 100);
    
    
            // 在canvas中rotate是累加的,以(0,0)为原点旋转 45 度
            ctx.rotate(22.1 * Math.PI / 180)
            ctx.rotate(22.9 * Math.PI / 180)
            // 与上面的代码的等价
            // ctx.rotate(45 * Math.PI / 180)
    
            ctx.beginPath();
            ctx.fillStyle = '#000';
            ctx.fillRect(0, 0, 200, 100);
        }
    }
    </script>
    
  2. 效果展示

案例:缩放

  1. 示例代码

    <canvas id="canvas" width="600px" height="250px"></canvas>
    <script>
    window.onload = function () {
        var canvas = document.querySelector("#canvas");
        if (canvas.getContext) {
            var ctx = canvas.getContext("2d");
            ctx.beginPath();
            ctx.scale(2, 2);
            ctx.fillRect(0, 0, 50, 50);
    
            ctx.beginPath();
            ctx.translate(100, 0);
            ctx.scale(2, 2);
            // 放大:使画布内css像素的个数变少,单个css像素所占据的实际物理尺寸变大
            // 缩小:使画布内css像素的个数变多,单个css像素所占据的实际物理尺寸变小
            ctx.fillRect(0, 0, 50, 50);
        }
    }
    </script>
    
  2. 效果展示

渐变

  1. API

    API 描述
    createLinearGradient(x1, y1, x2, y2) 创建线性渐变实例,渐变的起点 (x1, y1) 与终点 (x2, y2)
    createRadialGradient(x1, y1, r1, x2, y2, r2) 创建径向渐变实例
    · x1, y1, r1 定义另一个以 (x1, y1) 为原点,半径为 r1 的圆
    · x2, y2, r2 定义另一个以 (x2, y2) 为原点,半径为 r2 的圆
    addColorStop(position, color) 定义渐变
    · position 参数必须是一个 0.01.0 之间的数值,表示渐变中颜色所在的相对位置
    · color 参数必须是一个有效的 CSS 颜色值

案例:线性渐变

  1. 示例代码

    <canvas id="canvas" width="600px" height="250px"></canvas>
    <script>
    window.onload = function () {
        var canvas = document.querySelector("#canvas");
        if (canvas.getContext) {
            var ctx = canvas.getContext("2d");
    
            var gradient = ctx.createLinearGradient(0, 0, 200, 200);
            gradient.addColorStop(0, "red");
            gradient.addColorStop(0.5, "yellow");
            gradient.addColorStop(0.7, "pink");
            gradient.addColorStop(1, "green");
    
            ctx.fillStyle = gradient;
            ctx.fillRect(0, 0, 300, 300);
        }
    }
    </script>
    
  2. 效果展示

案例:径向渐变

  1. 示例代码

    <canvas id="canvas" width="600px" height="250px"></canvas>
    <script>
    window.onload = function () {
        var canvas = document.querySelector("#canvas");
        if (canvas.getContext) {
            var ctx = canvas.getContext("2d");
    
            var gradient = ctx.createRadialGradient(130, 130, 50, 130, 130, 100)
            gradient.addColorStop(0, "red");
            gradient.addColorStop(0.5, "yellow");
            gradient.addColorStop(0.7, "pink");
            gradient.addColorStop(1, "green");
    
            ctx.fillStyle = gradient;
            ctx.fillRect(0, 0, 300, 300);
        }
    }
    </script>
    
  2. 效果展示

合成

  1. 属性

    属性 描述
    globalAlpha 影响到 canvas 里所有图形的透明度,有效的值范围是 0.0 ~ 1.0 ,默认是 1.0
    globalCompositeOperation 覆盖合成
    · source-over:在目标图像上显示源图像(默认)
    · source-atop:在目标图像顶部显示源图像,源图像位于目标图像之外的部分是不可见的
    · source-atop:在目标图像顶部显示源图像,源图像位于目标图像之外的部分是不可见的
    · source-in:在目标图像中显示源图像,只有目标图像内的源图像部分会显示,目标图像是透明的
    · source-out:在目标图像之外显示源图像,只会显示目标图像之外源图像部分,目标图像是透明的
    · destination-over:在源图像上方显示目标图像
    · destination-atop:在源图像顶部显示目标图像,源图像之外的目标图像部分不会被显示
    · destination-in:在源图像中显示目标图像,只有源图像内的目标图像部分会被显示,源图像是透明的
    · destination-out:在源图像外显示目标图像,只有源图像外的目标图像部分会被显示,源图像是透明的
    · lighter:显示源图像 + 目标图像
    · copy:显示源图像,忽略目标图像
    · xor:使用异或操作对源图像与目标图像进行组合

案例:全局透明度

  1. 示例代码:

    <canvas id="canvas" width="600px" height="250px"></canvas>
    <script>
    window.onload = function () {
        var canvas = document.querySelector("#canvas");
        if (canvas.getContext) {
            var ctx = canvas.getContext("2d");
    
            ctx.fillStyle = "red";
            ctx.fillRect(0, 0, 100, 100);
    
            ctx.fillStyle = "red";
            ctx.globalAlpha = 0.5;
            ctx.fillRect(100, 100, 100, 100);
        }
    }
    </script>
    
  2. 效果展示

案例:覆盖合成

  1. 示例代码

    <body></body>
    <script>
    function canvasApp() {
        var w = 300;
        var h = 300;
    
        var compositing = ['source-over', 'source-in', 'source-out', 'source-atop', 'destination-over', 'destination-in', 'destination-out', 'destination-atop', 'lighter', 'copy', 'xor', 'multiply', 'screen', 'overlay', 'darken', 'lighten', 'color-dodge', 'color-burn', 'hard-light', 'soft-light', 'difference', 'exclusion', 'hue', 'saturation', 'color', 'luminosity'];
        var len = compositing.length;
    
        function drawScreen() {
            for (var i = 0; i < len; i++) {
                var canvas = document.createElement('canvas');
                canvas.width = w;
                canvas.height = h;
                document.body.append(canvas);
            }
    
            var canvas = document.querySelectorAll('canvas');
    
            for (var i = 0; i < canvas.length; i++) {
                var ctx = canvas[i].getContext('2d');
    
                ctx.save();
                ctx.translate(w / 2, h / 2);
                ctx.fillStyle = 'red';
                ctx.beginPath();
                ctx.arc(-40, 20, 80, 0, Math.PI * 2, true);
                ctx.closePath();
                ctx.fill();
    
                ctx.globalCompositeOperation = compositing[i];
    
                ctx.fillStyle = 'orange';
                ctx.beginPath();
                ctx.arc(40, 20, 80, 0, Math.PI * 2, true);
                ctx.closePath();
                ctx.fill();
                ctx.restore();
    
                ctx.fillStyle = 'black';
                ctx.textBaseline = 'middle';
                ctx.textAlign = 'center';
                ctx.font = '30px Arial';
                ctx.fillText((i + 1) + ': ' + compositing[i], w / 2, 40);
            }
        }
        drawScreen();
    }
    
    canvasApp();
    </script>
    
  2. 效果展示

canvas 使用图片、像素操作

使用图片

  1. API

    API 描述
    drawImage(image, x, y, width, height) 插入图片
    · image 图像源是 image 或者 canvas 对象
    · xycanvas 里的起始坐标
    · widthheight 这两个参数用来控制向 canvas 画入时应该缩放的大小
    createPattern(image, repetition) 在指定的方向内重复指定的元素
    · image 图像源是 image 或者 canvas 对象
    · repetitionrepeatrepeat-xrepeat-yno-repeat

案例:插入图片

  1. 示例代码

    <canvas id="canvas" width="600px" height="250px"></canvas>
    <script type="text/javascript">
    window.onload = function () {
        var canvas = document.querySelector("#canvas");
        if (canvas.getContext) {
            var ctx = canvas.getContext("2d");
    
            var img = new Image();
            img.src = "./meimei.jpg";
    
            img.onload = function () {
                // 如果绘制其他位置需要改变圆心,使用 translate
                ctx.scale(0.2, 0.2);
                ctx.drawImage(img, 0, 0, img.width, img.height);
            }
        }
    }
    </script>
    
  2. 效果展示

案例:设置背景

  1. 示例代码

    <canvas id="canvas" width="600px" height="250px"></canvas>
    <script type="text/javascript">
    window.onload = function () {
        var canvas = document.querySelector("#canvas");
        if (canvas.getContext) {
            var ctx = canvas.getContext("2d");
    
            var img = new Image();
            img.src = "./meimei.jpg";
    
            img.onload = function () {
                ctx.fillStyle = ctx.createPattern(img, "no-repeat");
                ctx.scale(0.2, 0.2);
                // 如果绘制其他位置需要改变圆心,使用 translate
                ctx.fillRect(0, 0, img.width, img.height);
            }
        }
    }
    </script>
    
  2. 效果展示

像素操作

  1. API

    API 描述
    ctx.createImageData(width, height) 创建 ImageData 对象,默认是透明的 rgba(0,0,0,0)
    · widthImageData 新对象的宽度
    · heightImageData 新对象的高度
    ctx.putImageData(myImageData, dx, dy) 在场景中写入像素数据
    · dx:表示绘制的像素数据的设备左上角 x 坐标
    · dy:表示绘制的像素数据的设备左上角 y 坐标
    ctx.getImageData(sx, sy, sw, sh) 获得一个包含画布场景像素数据的 ImageData 对像,代表了画布区域的对象数据
    · sx:将要被提取的图像数据矩形区域的左上角 x 坐标
    · sy:将要被提取的图像数据矩形区域的左上角 y 坐标
    · sw:将要被提取的图像数据矩形区域的宽度
    · sh:将要被提取的图像数据矩形区域的高度
  2. ImageData 对象

    1. width:图片宽度,单位是像素;
    2. height:图片高度,单位是像素;
    3. dataUint8ClampedArray 类型的一维数组,包含着 RGBA 格式 的整型数据,范围在 [0,255] 之间;
      1. R0 --> 255(黑色到白色);
      2. G0 --> 255(黑色到白色);
      3. B0 --> 255(黑色到白色);
      4. A0 --> 255(透明到不透明);

获取 ImageData 对象

  1. 示例代码

    window.onload = function () {
        var canvas = document.querySelector("#test");
        if (canvas.getContext) {
            var ctx = canvas.getContext("2d");
    
            ctx.fillStyle = "rgba(241, 223, 12,1)";
            ctx.fillRect(0, 0, 100, 100);
    
            /*imageData
                width:横向上像素点的个数
                height:纵向上像素点的个数
                data:数组:每一个像素点的rgba信息占 4 个长度
            */
    
            // 100*100,10000个像素点
            var imageData = ctx.getImageData(0, 0, 100, 100);
            console.log(imageData);
    
            for (var i = 0; i < imageData.data.length; i++) {
                imageData.data[4 * i + 3] = 100; // 改变每个像素的 透明度
            }
    
            // 将处理过的 像素 对象 放回到 (0,0)
            ctx.putImageData(imageData, 0, 0);
        }
    }
    
  2. 输出展示

创建 ImageData 对象

  1. 示例代码

    window.onload = function () {
        var canvas = document.querySelector("#test");
        if (canvas.getContext) {
            var ctx = canvas.getContext("2d");
    
            /*imageData
                width:横向上像素点的个数
                height:纵向上像素点的个数
                data:数组:每一个像素点的rgba信息占 4 个长度
            */
    
            // 100*100,10000个像素点
            var imageData = ctx.createImageData(100, 100);
    
            for (var i = 0; i < imageData.data.length; i++) {
                imageData.data[4 * i + 3] = 255; // 设置成黑色
            }
    
            // 将处理过的 像素 对象 放回到 (100,100)
            ctx.putImageData(imageData, 100, 100);
        }
    }
    
  2. 输出展示

canvas 导出、事件

  1. 导出、事件 API

    API 描述
    toDataURL() canvas 元素上的方法,将画布导出为图像
    ctx.isPointInPath(x, y) 判断在当前路径中是否包含检测点,此方法只作用于最新画出的 canvas 图像

案例:将画布导出为图像

  1. 示例代码

    <body>
    <canvas id="canvas"></canvas>
    </body>
    <script>
    window.onload = function () {
        var canvas = document.querySelector("#canvas");
        if (canvas.getContext) {
            var ctx = canvas.getContext("2d");
    
            ctx.fillRect(0, 0, 199, 199);
            var result = canvas.toDataURL(); // 生成 base64 格式的文件
    
            const img = new Image();
            img.src = result;
            document.body.append(img);
        }
    }
    </script>
    
  2. 效果展示

案例:事件操作

  1. 示例代码

    <canvas id="canvas"></canvas>
    <script>
        window.onload = function () {
            var canvas = document.querySelector("#canvas");
            if (canvas.getContext) {
                var ctx = canvas.getContext("2d");
    
                ctx.beginPath();
                ctx.fillStyle = "pink";
                ctx.arc(100, 100, 50, 0, 360 * Math.PI / 180);
                ctx.fill();
    
                moveTo(200, 200)
                ctx.beginPath();
                ctx.fillStyle = "#000";
                ctx.arc(180, 100, 50, 0, 360 * Math.PI / 180);
                ctx.fill();
    
                canvas.onclick = function (ev) {
                    ev = ev || event;
                    var x = ev.clientX - canvas.offsetLeft;
                    var y = ev.clientY - canvas.offsetTop;
                    if (ctx.isPointInPath(x, y)) {
                        alert('当前路径包含检测点...');
                    }
                }
            }
        }
    </script>
    
  2. 效果展示

canvas 案例

缩放

// IIFE包裹,避免全局变量污染
(function () {
  // 配置项集中管理,便于后续调整
  const ANIM_CONFIG = {
    CANVAS_ID: 'canvas',
    ORIGIN_X: 150,        // 旋转/缩放原点X
    ORIGIN_Y: 150,        // 旋转/缩放原点Y
    RECT_SIZE: 100,       // 矩形尺寸(宽高)
    ROTATE_SPEED: 1,      // 旋转速度(度/帧)
    SCALE_MIN: 0,         // 最小缩放比例(原scale=0 → 0/50=0)
    SCALE_MAX: 2,         // 最大缩放比例(原scale=100 → 100/50=2)
    SCALE_STEP: 0.02,     // 缩放步长(优化为更小值,动画更平滑)
    MARGIN: 20,           // 画布边距(原代码的-20)
    FRAME_INTERVAL: 10    // 帧间隔(ms)
  };

  let canvas, ctx, dpr;
  let rotation = 0;      // 旋转角度(替代原flag,语义更清晰)
  let scale = ANIM_CONFIG.SCALE_MIN; // 缩放比例(直接存储最终值,避免除法)
  let scaleDirection = 1; // 缩放方向(1=放大,-1=缩小)

  // 初始化函数,职责单一
  function initAnimation() {
    canvas = document.getElementById(ANIM_CONFIG.CANVAS_ID);
    if (!canvas || !canvas.getContext) {
      console.warn('当前浏览器不支持Canvas动画');
      return;
    }

    ctx = canvas.getContext('2d');
    dpr = window.devicePixelRatio || 1; // 设备像素比

    // 初始化画布尺寸(高清+响应式)
    resizeCanvas();
    // 监听窗口resize,动态适配
    window.addEventListener('resize', resizeCanvas);
    // 启动动画
    startAnimation();
  }

  // 画布尺寸适配(处理高清显示和窗口变化)
  function resizeCanvas() {
    // 计算可用尺寸(减去边距)
    const availableWidth = document.documentElement.clientWidth - ANIM_CONFIG.MARGIN;
    const availableHeight = document.documentElement.clientHeight - ANIM_CONFIG.MARGIN;

    // 样式尺寸(视觉大小)
    canvas.style.width = `${availableWidth}px`;
    canvas.style.height = `${availableHeight}px`;

    // 实际像素尺寸(高清适配:乘以设备像素比)
    canvas.width = availableWidth * dpr;
    canvas.height = availableHeight * dpr;

    // 缩放上下文,避免绘制模糊
    ctx.scale(dpr, dpr);
  }

  // 绘制矩形(提取独立函数,职责单一)
  function drawRect() {
    // 清空画布(使用视觉尺寸,避免多余计算)
    const availableWidth = document.documentElement.clientWidth - ANIM_CONFIG.MARGIN;
    const availableHeight = document.documentElement.clientHeight - ANIM_CONFIG.MARGIN;
    ctx.clearRect(0, 0, availableWidth, availableHeight);

    ctx.save();
    // 平移到旋转/缩放原点
    ctx.translate(ANIM_CONFIG.ORIGIN_X, ANIM_CONFIG.ORIGIN_Y);
    // 旋转(弧度计算)
    ctx.rotate(rotation * Math.PI / 180);
    // 缩放(直接使用最终比例,避免重复除法)
    ctx.scale(scale, scale);

    // 绘制矩形(中心在原点,简化计算)
    ctx.beginPath();
    const halfSize = ANIM_CONFIG.RECT_SIZE / 2;
    ctx.fillRect(-halfSize, -halfSize, ANIM_CONFIG.RECT_SIZE, ANIM_CONFIG.RECT_SIZE);

    ctx.restore();
  }

  // 动画更新逻辑
  function updateAnimation() {
    // 更新旋转角度(取模360,避免数值无限增大)
    rotation = (rotation + ANIM_CONFIG.ROTATE_SPEED) % 360;

    // 更新缩放比例(边界判断更严谨,避免溢出)
    scale += scaleDirection * ANIM_CONFIG.SCALE_STEP;
    if (scale >= ANIM_CONFIG.SCALE_MAX) {
      scale = ANIM_CONFIG.SCALE_MAX;
      scaleDirection = -1;
    } else if (scale <= ANIM_CONFIG.SCALE_MIN) {
      scale = ANIM_CONFIG.SCALE_MIN;
      scaleDirection = 1;
    }

    // 绘制当前帧
    drawRect();
  }

  // 启动动画(兼容requestAnimationFrame和setInterval)
  function startAnimation() {
    // 优先使用requestAnimationFrame(更流畅,贴合屏幕刷新)
    if (window.requestAnimationFrame) {
      let lastTime = 0;
      function animate(timestamp) {
        // 控制帧间隔(确保与配置一致)
        if (timestamp - lastTime >= ANIM_CONFIG.FRAME_INTERVAL) {
          updateAnimation();
          lastTime = timestamp;
        }
        requestAnimationFrame(animate);
      }
      requestAnimationFrame(animate);
    } else {
      // 降级使用setInterval(兼容旧浏览器)
      setInterval(updateAnimation, ANIM_CONFIG.FRAME_INTERVAL);
    }
  }

  // 页面DOM就绪后初始化(比window.onload触发更早)
  document.addEventListener('DOMContentLoaded', initAnimation);
})();

表盘

// 立即执行函数避免全局污染
(function () {
  // 配置项集中管理,便于修改
  const CLOCK_CONFIG = {
    RADIUS: 140,          // 时钟半径
    CENTER_X: 140,        // 时钟中心X(与半径一致,简化计算)
    CENTER_Y: 140,        // 时钟中心Y
    BORDER_WIDTH: 14,     // 外边框宽度
    BORDER_COLOR: '#325FA2', // 外边框颜色
    HOUR_MARK_WIDTH: 8,   // 时针刻度宽度
    HOUR_MARK_LENGTH: 20, // 时针刻度长度
    MINUTE_MARK_WIDTH: 4, // 分针刻度宽度
    MINUTE_MARK_LENGTH: 3,// 分针刻度长度
    HOUR_HAND_WIDTH: 14,  // 时针宽度
    HOUR_HAND_LENGTH: 80, // 时针长度
    MINUTE_HAND_WIDTH: 10,// 分针宽度
    MINUTE_HAND_LENGTH: 112,// 分针长度
    SECOND_HAND_WIDTH: 6, // 秒针宽度
    SECOND_HAND_LENGTH: 83,// 秒针长度
    SECOND_COLOR: '#D40000',// 秒针颜色
    CENTER_DOT_RADIUS: 10,// 中心圆点半径
    SECOND_DOT_RADIUS: 10,// 秒针头部圆点半径
    UPDATE_INTERVAL: 1000 // 更新间隔(ms)
  };

  let canvas, ctx, dpr; // 全局变量(仅在IIFE内)

  // 初始化函数
  function initClock() {
    canvas = document.getElementById('clock');
    if (!canvas || !canvas.getContext) {
      console.warn('当前浏览器不支持Canvas时钟');
      return;
    }

    ctx = canvas.getContext('2d');
    dpr = window.devicePixelRatio || 1; // 设备像素比

    // 初始化画布尺寸
    resizeCanvas();
    // 监听窗口 resize 适配
    window.addEventListener('resize', resizeCanvas);
    // 启动时钟更新
    updateClock();
    setInterval(updateClock, CLOCK_CONFIG.UPDATE_INTERVAL);
  }

  // 画布尺寸适配(高清+响应式)
  function resizeCanvas() {
    // 计算可用尺寸(减去padding,避免溢出)
    const availableWidth = window.innerWidth - 20;
    const availableHeight = window.innerHeight - 20;
    // 时钟最大可显示尺寸(基于半径的两倍)
    const maxClockSize = Math.min(availableWidth, availableHeight);
    // 实际显示尺寸(确保是偶数,避免模糊)
    const displaySize = Math.floor(maxClockSize / 2) * 2;

    // 样式尺寸(视觉大小)
    canvas.style.width = `${displaySize}px`;
    canvas.style.height = `${displaySize}px`;
    // 实际像素尺寸(高清适配)
    canvas.width = displaySize * dpr;
    canvas.height = displaySize * dpr;

    // 缩放上下文,确保绘制清晰
    ctx.scale(dpr, dpr);
    // 重新计算中心位置(基于显示尺寸居中)
    CLOCK_CONFIG.CENTER_X = displaySize / 2;
    CLOCK_CONFIG.CENTER_Y = displaySize / 2;
  }

  // 绘制外边框
  function drawBorder() {
    ctx.save();
    ctx.strokeStyle = CLOCK_CONFIG.BORDER_COLOR;
    ctx.lineWidth = CLOCK_CONFIG.BORDER_WIDTH;
    ctx.lineCap = 'round';
    ctx.beginPath();
    ctx.arc(
      CLOCK_CONFIG.CENTER_X,
      CLOCK_CONFIG.CENTER_Y,
      CLOCK_CONFIG.RADIUS,
      0,
      Math.PI * 2
    );
    ctx.stroke();
    ctx.restore();
  }

  // 绘制刻度(时针+分针)
  function drawMarks() {
    // 绘制时针刻度(12个)
    ctx.save();
    ctx.strokeStyle = '#000';
    ctx.lineWidth = CLOCK_CONFIG.HOUR_MARK_WIDTH;
    ctx.lineCap = 'round';
    // 移动到中心,方便旋转
    ctx.translate(CLOCK_CONFIG.CENTER_X, CLOCK_CONFIG.CENTER_Y);

    for (let i = 0; i < 12; i++) {
      ctx.rotate(Math.PI / 6); // 30度/个(360/12)
      ctx.beginPath();
      ctx.moveTo(CLOCK_CONFIG.RADIUS - CLOCK_CONFIG.HOUR_MARK_LENGTH, 0);
      ctx.lineTo(CLOCK_CONFIG.RADIUS, 0);
      ctx.stroke();
    }
    ctx.restore();

    // 绘制分针刻度(60个,跳过时针刻度位置)
    ctx.save();
    ctx.strokeStyle = '#000';
    ctx.lineWidth = CLOCK_CONFIG.MINUTE_MARK_WIDTH;
    ctx.lineCap = 'round';
    ctx.translate(CLOCK_CONFIG.CENTER_X, CLOCK_CONFIG.CENTER_Y);

    for (let i = 0; i < 60; i++) {
      // 跳过时针刻度(每5个)
      if (i % 5 === 0) continue;
      ctx.rotate(Math.PI / 30); // 6度/个(360/60)
      ctx.beginPath();
      ctx.moveTo(CLOCK_CONFIG.RADIUS - CLOCK_CONFIG.MINUTE_MARK_LENGTH, 0);
      ctx.lineTo(CLOCK_CONFIG.RADIUS, 0);
      ctx.stroke();
    }
    ctx.restore();
  }

  // 绘制指针(时针+分针+秒针)
  function drawHands() {
    const now = new Date();
    const seconds = now.getSeconds();
    const minutes = now.getMinutes() + seconds / 60;
    const hours = now.getHours() % 12 + minutes / 60; // 12小时制

    // 时针(30度/小时)
    drawHand(
      hours * Math.PI / 6,
      CLOCK_CONFIG.HOUR_HAND_LENGTH,
      CLOCK_CONFIG.HOUR_HAND_WIDTH,
      '#000'
    );

    // 分针(6度/分钟)
    drawHand(
      minutes * Math.PI / 30,
      CLOCK_CONFIG.MINUTE_HAND_LENGTH,
      CLOCK_CONFIG.MINUTE_HAND_WIDTH,
      '#000'
    );

    // 秒针(6度/秒)
    drawHand(
      seconds * Math.PI / 30,
      CLOCK_CONFIG.SECOND_HAND_LENGTH,
      CLOCK_CONFIG.SECOND_HAND_WIDTH,
      CLOCK_CONFIG.SECOND_COLOR,
      true // 显示秒针头部和中心圆点
    );
  }

  // 通用绘制指针函数(减少代码冗余)
  function drawHand(angle, length, width, color, isSecond = false) {
    ctx.save();
    ctx.strokeStyle = color;
    ctx.fillStyle = color;
    ctx.lineWidth = width;
    ctx.lineCap = 'round';
    ctx.translate(CLOCK_CONFIG.CENTER_X, CLOCK_CONFIG.CENTER_Y);
    ctx.rotate(angle);

    // 绘制指针线条
    ctx.beginPath();
    ctx.moveTo(-20, 0); // 指针尾部延伸
    ctx.lineTo(length, 0);
    ctx.stroke();

    // 秒针专属:中心圆点和头部圆点
    if (isSecond) {
      // 中心圆点
      ctx.beginPath();
      ctx.arc(0, 0, CLOCK_CONFIG.CENTER_DOT_RADIUS, 0, Math.PI * 2);
      ctx.fill();

      // 秒针头部圆点
      ctx.beginPath();
      ctx.arc(length + 13, 0, CLOCK_CONFIG.SECOND_DOT_RADIUS, 0, Math.PI * 2);
      ctx.stroke();
    }

    ctx.restore();
  }

  // 更新时钟(清空画布+重新绘制)
  function updateClock() {
    // 清空画布(使用实际显示尺寸,避免多余计算)
    const displayWidth = canvas.style.width.replace('px', '');
    const displayHeight = canvas.style.height.replace('px', '');
    ctx.clearRect(0, 0, displayWidth, displayHeight);

    // 按层级绘制
    drawBorder();
    drawMarks();
    drawHands();
  }

  // 页面加载完成后初始化
  document.addEventListener('DOMContentLoaded', initClock);
})();

马赛克

'use strict';

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();

// 配置参数
const mosaicSize = 5;
const padding = 10;
const imgSrc = '/example/meimei.jpg';
const dpr = window.devicePixelRatio || 1;

// 调整Canvas尺寸
function resizeCanvas() {
  const w = document.documentElement.clientWidth - padding * 2;
  const h = document.documentElement.clientHeight - padding * 2;

  canvas.style.width = `${w}px`;
  canvas.style.height = `${h}px`;
  canvas.width = w * dpr;
  canvas.height = h * dpr;

  ctx.scale(dpr, dpr);
}

// 创建马赛克数据
function createMosaicData(imgData, blockSize) {
  const { width, height, data } = imgData;
  const mosaicData = ctx.createImageData(width, height);
  const mosaicArr = mosaicData.data;

  const blockCols = Math.floor(width / blockSize);
  const blockRows = Math.floor(height / blockSize);

  for (let row = 0; row < blockRows; row++) {
    for (let col = 0; col < blockCols; col++) {
      const blockX = col * blockSize;
      const blockY = row * blockSize;

      // 随机选取块内像素
      const randomX = Math.min(blockX + Math.floor(Math.random() * blockSize), width - 1);
      const randomY = Math.min(blockY + Math.floor(Math.random() * blockSize), height - 1);

      // 获取颜色值
      const colorIdx = (randomY * width + randomX) * 4;
      const r = data[colorIdx];
      const g = data[colorIdx + 1];
      const b = data[colorIdx + 2];
      const a = data[colorIdx + 3];

      // 填充整个块
      for (let y = 0; y < blockSize; y++) {
        for (let x = 0; x < blockSize; x++) {
          const pxX = blockX + x;
          const pxY = blockY + y;
          if (pxX >= width || pxY >= height) continue;

          const targetIdx = (pxY * width + pxX) * 4;
          mosaicArr[targetIdx] = r;
          mosaicArr[targetIdx + 1] = g;
          mosaicArr[targetIdx + 2] = b;
          mosaicArr[targetIdx + 3] = a;
        }
      }
    }
  }

  return mosaicData;
}

// 绘制马赛克效果
function drawMosaic() {
  resizeCanvas();

  // 固定尺寸绘制(可根据需要修改)
  const drawWidth = 240;
  const drawHeight = 350;

  // 绘制原图
  ctx.drawImage(img, 0, 0, drawWidth, drawHeight);

  // 获取像素数据并创建马赛克
  const imgData = ctx.getImageData(0, 0, drawWidth, drawHeight);
  const mosaicData = createMosaicData(imgData, mosaicSize);

  // 绘制马赛克(并排显示)
  ctx.putImageData(mosaicData, drawWidth + 20, 0);
}

// 图片加载处理
img.crossOrigin = 'anonymous';
img.src = imgSrc;
img.onload = drawMosaic;

// 窗口 resize 事件
window.addEventListener('resize', () => {
  clearTimeout(window.resizeTimer);
  window.resizeTimer = setTimeout(() => {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    img.complete && drawMosaic();
  }, 100);
});

// 初始化
resizeCanvas();

绘制直角坐标系

'use strict';

// 配置项集中管理,便于调整样式和尺寸
const AXIS_CONFIG = Object.freeze({
  CANVAS_ID: 'canvas',
  PAD: 40,            // 坐标轴内边距(优化默认值,避免刻度贴边)
  BOTTOM_PAD: 40,     // 底部额外内边距(适配刻度文字)
  STEP: 100,          // 刻度间隔
  AXIS_WIDTH: 2,      // 坐标轴线宽
  AXIS_COLOR: 'lightblue', // 坐标轴颜色
  TICK_WIDTH: 1,      // 刻度线宽
  TICK_COLOR: '#666', // 刻度颜色
  TICK_LENGTH: 10,    // 刻度长度
  TEXT_COLOR: '#333', // 刻度文字颜色
  TEXT_FONT: '12px Arial', // 刻度文字字体
  DPR: window.devicePixelRatio || 1 // 设备像素比
});

// 全局状态管理
const state = {
  canvas: null,
  ctx: null,
  canvasWidth: 0,     // 画布实际像素宽度
  canvasHeight: 0,    // 画布实际像素高度
  displayWidth: 0,    // 画布显示宽度
  displayHeight: 0    // 画布显示高度
};

// 初始化入口函数
function initAxis() {
  // 初始化DOM和上下文
  state.canvas = document.getElementById(AXIS_CONFIG.CANVAS_ID);
  if (!state.canvas) {
    console.error('未找到Canvas元素');
    return;
  }

  state.ctx = state.canvas.getContext('2d');
  if (!state.ctx) {
    alert('您的浏览器不支持Canvas,请升级浏览器');
    return;
  }

  // 初始化画布尺寸(高清适配)
  initCanvasSize();
  // 绘制坐标系
  drawCoordinateAxis();
}

// 初始化画布尺寸(修复高清适配逻辑)
function initCanvasSize() {
  const { canvas, ctx } = state;
  const { DPR } = AXIS_CONFIG;

  // 获取画布默认显示尺寸(从canvas属性读取)
  state.displayWidth = canvas.width;
  state.displayHeight = canvas.height;

  // 高清适配:实际像素尺寸 = 显示尺寸 * DPR
  state.canvasWidth = state.displayWidth * DPR;
  state.canvasHeight = state.displayHeight * DPR;

  // 设置画布样式和实际尺寸
  canvas.style.width = `${state.displayWidth}px`;
  canvas.style.height = `${state.displayHeight}px`;
  canvas.width = state.canvasWidth;
  canvas.height = state.canvasHeight;

  // 缩放上下文,确保绘制清晰(关键修复:避免重复缩放)
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.scale(DPR, DPR);
}

// 绘制坐标轴(主函数)
function drawCoordinateAxis() {
  const { ctx, displayWidth, displayHeight } = state;
  const { PAD, BOTTOM_PAD, STEP } = AXIS_CONFIG;

  // 计算坐标轴实际位置(基于显示尺寸,避免DPR干扰)
  const xAxisY = displayHeight - BOTTOM_PAD; // X轴Y坐标
  const yAxisX = PAD;                        // Y轴X坐标

  // 清空画布
  ctx.clearRect(0, 0, displayWidth, displayHeight);

  // 绘制X轴和Y轴
  drawAxisLines(yAxisX, xAxisY);
  // 绘制X轴刻度和文字
  drawXAxisTicks(yAxisX, xAxisY);
  // 绘制Y轴刻度和文字
  drawYAxisTicks(yAxisX, xAxisY);
}

// 绘制坐标轴线条(X轴+Y轴)
function drawAxisLines(yAxisX, xAxisY) {
  const { ctx, displayWidth, displayHeight } = state;
  const { AXIS_WIDTH, AXIS_COLOR, PAD } = AXIS_CONFIG;

  ctx.save();
  ctx.lineWidth = AXIS_WIDTH;
  ctx.strokeStyle = AXIS_COLOR;
  ctx.beginPath();

  // Y轴:从顶部内边距到X轴
  ctx.moveTo(yAxisX, PAD);
  ctx.lineTo(yAxisX, xAxisY);

  // X轴:从Y轴到右侧内边距
  ctx.moveTo(yAxisX, xAxisY);
  ctx.lineTo(displayWidth - PAD, xAxisY);

  ctx.stroke();
  ctx.restore();
}

// 绘制X轴刻度和文字
function drawXAxisTicks(yAxisX, xAxisY) {
  const { ctx, displayWidth } = state;
  const { STEP, TICK_WIDTH, TICK_COLOR, TICK_LENGTH, TEXT_COLOR, TEXT_FONT, PAD } = AXIS_CONFIG;

  ctx.save();
  ctx.lineWidth = TICK_WIDTH;
  ctx.strokeStyle = TICK_COLOR;
  ctx.fillStyle = TEXT_COLOR;
  ctx.font = TEXT_FONT;
  ctx.textAlign = 'center'; // 文字水平居中
  ctx.textBaseline = 'top'; // 文字基于刻度线顶部对齐

  // 计算X轴刻度数量(从Y轴到右侧内边距)
  const maxX = displayWidth - PAD;
  const tickCount = Math.floor((maxX - yAxisX) / STEP);

  for (let i = 1; i <= tickCount; i++) {
    const tickX = yAxisX + i * STEP;

    // 绘制刻度线
    ctx.beginPath();
    ctx.moveTo(tickX, xAxisY);
    ctx.lineTo(tickX, xAxisY + TICK_LENGTH); // X轴刻度向下
    ctx.stroke();

    // 绘制刻度文字(显示刻度值,基于间隔计算)
    const text = String(i * STEP);
    ctx.fillText(text, tickX, xAxisY + TICK_LENGTH + 4); // 文字与刻度线间距4px
  }

  ctx.restore();
}

// 绘制Y轴刻度和文字
function drawYAxisTicks(yAxisX, xAxisY) {
  const { ctx, displayHeight } = state;
  const { STEP, TICK_WIDTH, TICK_COLOR, TICK_LENGTH, TEXT_COLOR, TEXT_FONT, PAD } = AXIS_CONFIG;

  ctx.save();
  ctx.lineWidth = TICK_WIDTH;
  ctx.strokeStyle = TICK_COLOR;
  ctx.fillStyle = TEXT_COLOR;
  ctx.font = TEXT_FONT;
  ctx.textAlign = 'right'; // 文字右对齐
  ctx.textBaseline = 'middle'; // 文字垂直居中

  // 计算Y轴刻度数量(从X轴到顶部内边距)
  const minY = PAD;
  const tickCount = Math.floor((xAxisY - minY) / STEP);

  for (let i = 1; i <= tickCount; i++) {
    const tickY = xAxisY - i * STEP;

    // 绘制刻度线
    ctx.beginPath();
    ctx.moveTo(yAxisX - TICK_LENGTH, tickY); // Y轴刻度向左
    ctx.lineTo(yAxisX, tickY);
    ctx.stroke();

    // 绘制刻度文字(显示刻度值)
    const text = String(i * STEP);
    ctx.fillText(text, yAxisX - TICK_LENGTH - 4, tickY); // 文字与刻度线间距4px
  }

  ctx.restore();
}

// DOM加载完成后初始化
document.addEventListener('DOMContentLoaded', initAxis);

绘制直方图

'use strict';

// 配置项集中管理(新增直方图相关配置)
const CONFIG = Object.freeze({
  // 原有坐标系配置
  CANVAS_ID: 'canvas',
  PAD: 40,            // 坐标轴内边距
  BOTTOM_PAD: 40,     // 底部额外内边距
  STEP: 100,          // 刻度间隔
  AXIS_WIDTH: 2,      // 坐标轴线宽
  AXIS_COLOR: 'lightblue', // 坐标轴颜色
  TICK_WIDTH: 1,      // 刻度线宽
  TICK_COLOR: '#666', // 刻度颜色
  TICK_LENGTH: 10,    // 刻度长度
  TEXT_COLOR: '#333', // 刻度文字颜色
  TEXT_FONT: '12px Arial', // 刻度文字字体
  DPR: window.devicePixelRatio || 1, // 设备像素比

  // 新增直方图配置
  BAR_WIDTH: 35,      // 柱子宽度
  BAR_GAP: 15,        // 柱子之间的间距
  BAR_ROUNDED_RADIUS: 4, // 柱子圆角半径
  BAR_BORDER_COLOR: 'rgba(0,0,0,0.1)', // 柱子边框颜色
  BAR_BORDER_WIDTH: 1, // 柱子边框宽度
  // 直方图数据(可自定义修改)
  HISTOGRAM_DATA: [65, 120, 180, 95, 210, 150],
  // 颜色数组(与数据一一对应,可扩展)
  BAR_COLORS: [
    '#FF6B6B', '#4ECDC4', '#45B7D1',
    '#96CEB4', '#FECA57', '#FF9FF3'
  ]
});

// 全局状态管理(新增坐标轴关键位置存储)
const state = {
  canvas: null,
  ctx: null,
  canvasWidth: 0,     // 画布实际像素宽度
  canvasHeight: 0,    // 画布实际像素高度
  displayWidth: 0,    // 画布显示宽度
  displayHeight: 0,   // 画布显示高度
  xAxisY: 0,          // X轴Y坐标(新增)
  yAxisX: 0           // Y轴X坐标(新增)
};

// 初始化入口函数(修改:绘制坐标系后添加直方图)
function initAxis() {
  // 初始化DOM和上下文
  state.canvas = document.getElementById(CONFIG.CANVAS_ID);
  if (!state.canvas) {
    console.error('未找到Canvas元素');
    return;
  }

  state.ctx = state.canvas.getContext('2d');
  if (!state.ctx) {
    alert('您的浏览器不支持Canvas,请升级浏览器');
    return;
  }

  // 初始化画布尺寸(高清适配)
  initCanvasSize();
  // 绘制坐标系(修改:存储坐标轴关键位置)
  drawCoordinateAxis();
  // 新增:绘制直方图
  drawHistogram();
}

// 初始化画布尺寸(保持不变)
function initCanvasSize() {
  const { canvas, ctx } = state;
  const { DPR } = CONFIG;

  // 获取画布默认显示尺寸(从canvas属性读取)
  state.displayWidth = canvas.width;
  state.displayHeight = canvas.height;

  // 高清适配:实际像素尺寸 = 显示尺寸 * DPR
  state.canvasWidth = state.displayWidth * DPR;
  state.canvasHeight = state.displayHeight * DPR;

  // 设置画布样式和实际尺寸
  canvas.style.width = `${state.displayWidth}px`;
  canvas.style.height = `${state.displayHeight}px`;
  canvas.width = state.canvasWidth;
  canvas.height = state.canvasHeight;

  // 缩放上下文,确保绘制清晰
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.scale(DPR, DPR);
}

// 绘制坐标轴(主函数)(修改:存储xAxisY和yAxisX到state)
function drawCoordinateAxis() {
  const { ctx, displayWidth, displayHeight } = state;
  const { PAD, BOTTOM_PAD, STEP } = CONFIG;

  // 计算坐标轴实际位置(基于显示尺寸,避免DPR干扰)
  state.xAxisY = displayHeight - BOTTOM_PAD; // X轴Y坐标(存储到state)
  state.yAxisX = PAD;                        // Y轴X坐标(存储到state)

  // 清空画布
  ctx.clearRect(0, 0, displayWidth, displayHeight);

  // 绘制X轴和Y轴
  drawAxisLines();
  // 绘制X轴刻度和文字
  drawXAxisTicks();
  // 绘制Y轴刻度和文字
  drawYAxisTicks();
}

// 绘制坐标轴线条(修改:使用state中的坐标位置)
function drawAxisLines() {
  const { ctx, displayWidth } = state;
  const { AXIS_WIDTH, AXIS_COLOR, PAD } = CONFIG;

  ctx.save();
  ctx.lineWidth = AXIS_WIDTH;
  ctx.strokeStyle = AXIS_COLOR;
  ctx.beginPath();

  // Y轴:从顶部内边距到X轴
  ctx.moveTo(state.yAxisX, PAD);
  ctx.lineTo(state.yAxisX, state.xAxisY);

  // X轴:从Y轴到右侧内边距
  ctx.moveTo(state.yAxisX, state.xAxisY);
  ctx.lineTo(displayWidth - PAD, state.xAxisY);

  ctx.stroke();
  ctx.restore();
}

// 绘制X轴刻度和文字(修改:使用state中的坐标位置,适配直方图数据量)
function drawXAxisTicks() {
  const { ctx, displayWidth } = state;
  const { STEP, TICK_WIDTH, TICK_COLOR, TICK_LENGTH, TEXT_COLOR, TEXT_FONT, PAD } = CONFIG;
  const dataLength = CONFIG.HISTOGRAM_DATA.length; // 数据长度决定刻度数量

  ctx.save();
  ctx.lineWidth = TICK_WIDTH;
  ctx.strokeStyle = TICK_COLOR;
  ctx.fillStyle = TEXT_COLOR;
  ctx.font = TEXT_FONT;
  ctx.textAlign = 'center'; // 文字水平居中
  ctx.textBaseline = 'top'; // 文字基于刻度线顶部对齐

  // 计算X轴可绘制刻度数量(适配数据长度和画布宽度)
  const maxX = displayWidth - PAD;
  const tickCount = Math.min(dataLength, Math.floor((maxX - state.yAxisX) / STEP));

  for (let i = 1; i <= tickCount; i++) {
    const tickX = state.yAxisX + i * STEP;

    // 绘制刻度线
    ctx.beginPath();
    ctx.moveTo(tickX, state.xAxisY);
    ctx.lineTo(tickX, state.xAxisY + TICK_LENGTH); // X轴刻度向下
    ctx.stroke();

    // 绘制刻度文字(显示类别,与数据对应)
    const text = `类别${i}`;
    ctx.fillText(text, tickX, state.xAxisY + TICK_LENGTH + 4);
  }

  ctx.restore();
}

// 绘制Y轴刻度和文字(保持不变)
function drawYAxisTicks() {
  const { ctx, displayHeight } = state;
  const { STEP, TICK_WIDTH, TICK_COLOR, TICK_LENGTH, TEXT_COLOR, TEXT_FONT, PAD } = CONFIG;

  ctx.save();
  ctx.lineWidth = TICK_WIDTH;
  ctx.strokeStyle = TICK_COLOR;
  ctx.fillStyle = TEXT_COLOR;
  ctx.font = TEXT_FONT;
  ctx.textAlign = 'right'; // 文字右对齐
  ctx.textBaseline = 'middle'; // 文字垂直居中

  // 计算Y轴可绘制刻度数量(从X轴到顶部内边距)
  const minY = PAD;
  const tickCount = Math.floor((state.xAxisY - minY) / STEP);

  for (let i = 1; i <= tickCount; i++) {
    const tickY = state.xAxisY - i * STEP;

    // 绘制刻度线
    ctx.beginPath();
    ctx.moveTo(state.yAxisX - TICK_LENGTH, tickY); // Y轴刻度向左
    ctx.lineTo(state.yAxisX, tickY);
    ctx.stroke();

    // 绘制刻度文字(显示刻度值)
    const text = String(i * STEP);
    ctx.fillText(text, state.yAxisX - TICK_LENGTH - 4, tickY);
  }

  ctx.restore();
}

// 新增:绘制直方图核心函数
function drawHistogram() {
  const { ctx } = state;
  const {
    BAR_WIDTH, BAR_GAP, BAR_ROUNDED_RADIUS,
    BAR_BORDER_COLOR, BAR_BORDER_WIDTH,
    HISTOGRAM_DATA, BAR_COLORS, STEP
  } = CONFIG;

  ctx.save();

  // 遍历数据绘制每个柱子
  HISTOGRAM_DATA.forEach((value, index) => {
    // 计算柱子X坐标:基于X轴刻度位置,居中对齐
    const tickX = state.yAxisX + (index + 1) * STEP;
    const barX = tickX - BAR_WIDTH / 2; // 柱子居中于刻度

    // 计算柱子高度:数据值直接映射为像素高度(确保不超过坐标轴范围)
    const maxBarHeight = state.xAxisY - CONFIG.PAD; // 柱子最大可绘制高度
    const barHeight = Math.min(value, maxBarHeight); // 限制最大高度

    // 计算柱子Y坐标:从X轴向上绘制
    const barY = state.xAxisY - barHeight;

    // 设置柱子颜色(如果颜色数组长度不足,使用随机颜色)
    const barColor = BAR_COLORS[index] || getRandomColor();

    // 绘制圆角矩形柱子
    drawRoundedRect(barX, barY, BAR_WIDTH, barHeight, BAR_ROUNDED_RADIUS);

    // 填充柱子颜色
    ctx.fillStyle = barColor;
    ctx.fill();

    // 绘制柱子边框
    ctx.strokeStyle = BAR_BORDER_COLOR;
    ctx.lineWidth = BAR_BORDER_WIDTH;
    ctx.stroke();

    // 绘制柱子顶部数值标签
    drawBarValueLabel(barX, barY, BAR_WIDTH, barHeight, value);
  });

  ctx.restore();
}

// 新增:绘制圆角矩形工具函数
function drawRoundedRect(x, y, width, height, radius) {
  const { ctx } = state;
  ctx.beginPath();
  // 左上角
  ctx.moveTo(x + radius, y);
  // 右上角
  ctx.lineTo(x + width - radius, y);
  ctx.arcTo(x + width, y, x + width, y + radius, radius);
  // 右下角
  ctx.lineTo(x + width, y + height);
  ctx.lineTo(x, y + height);
  // 左下角
  ctx.lineTo(x, y + radius);
  ctx.arcTo(x, y, x + radius, y, radius);
  ctx.closePath();
}

// 新增:绘制柱子顶部数值标签
function drawBarValueLabel(x, y, width, height, value) {
  const { ctx } = state;
  const { TEXT_COLOR, TEXT_FONT } = CONFIG;

  ctx.save();
  ctx.fillStyle = TEXT_COLOR;
  ctx.font = TEXT_FONT;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'bottom';

  // 标签位置:柱子顶部中间,向上偏移5px
  const labelX = x + width / 2;
  const labelY = y - 5;

  ctx.fillText(value, labelX, labelY);
  ctx.restore();
}

// 新增:生成随机颜色工具函数(备用)
function getRandomColor() {
  return '#' + Math.floor(Math.random() * 0xffffff)
    .toString(16)
    .padStart(6, '0'); // 确保是6位合法颜色
}

// DOM加载完成后初始化
document.addEventListener('DOMContentLoaded', initAxis);

绘制饼图

'use strict';

// 配置项集中管理(南丁格尔图核心配置)
const NIGHTINGALE_CONFIG = Object.freeze({
  CANVAS_ID: 'canvas',
  CENTER_X: 300,       // 饼图中心X坐标(画布水平居中)
  CENTER_Y: 200,       // 饼图中心Y坐标(画布垂直居中)
  MIN_RADIUS: 60,      // 最小半径(数据最小时的半径)
  MAX_RADIUS: 140,     // 最大半径(数据最大时的半径)
  SHADOW_BLUR: 6,      // 阴影模糊度
  SHADOW_COLOR: 'rgba(0,0,0,0.2)', // 阴影颜色
  BORDER_WIDTH: 2,     // 扇区边框宽度
  BORDER_COLOR: '#fff', // 扇区边框颜色(分隔扇区,提升视觉)
  // 饼图数据(含名称、数值、颜色,支持hover提示)
  DATA: [
    { name: '类别1', value: 80, color: '#5C1918' },
    { name: '类别2', value: 100, color: '#A32D29' },
    { name: '类别3', value: 120, color: '#B9332E' },
    { name: '类别4', value: 90, color: '#842320' },
    { name: '类别5', value: 70, color: '#D76662' }
  ],
  // 图例配置
  LEGEND: {
    X: 450,            // 图例起始X坐标
    Y: 150,            // 图例起始Y坐标
    ITEM_WIDTH: 14,    // 图例色块宽度
    ITEM_HEIGHT: 14,   // 图例色块高度
    GAP: 22,           // 图例项间距
    TEXT_OFFSET: 10,   // 文字与色块间距
    FONT: '14px Arial',// 图例文字字体
    COLOR: '#333'      // 图例文字颜色
  },
  // 百分比标签配置
  LABEL: {
    FONT: '14px Arial',// 标签字体
    COLOR: '#fff',     // 标签颜色
    OFFSET: 0.8        // 标签距离中心的比例(基于当前扇区半径)
  },
  DPR: window.devicePixelRatio || 1 // 设备像素比(高清适配)
});

// 全局状态管理(新增hover检测相关状态)
const state = {
  canvas: null,
  ctx: null,
  canvasWidth: 0,     // 画布实际像素宽度
  canvasHeight: 0,    // 画布实际像素高度
  displayWidth: 0,    // 画布显示宽度
  displayHeight: 0,   // 画布显示高度
  totalValue: 0,      // 数据总和
  maxValue: 0,        // 数据最大值(用于计算半径比例)
  hoveredIndex: -1,   //  hover的扇区索引
  tooltip: null       // 提示框元素
};

// 初始化入口函数
function initNightingaleChart() {
  // 初始化DOM和上下文
  state.canvas = document.getElementById(NIGHTINGALE_CONFIG.CANVAS_ID);
  if (!state.canvas) {
    console.error('未找到Canvas元素');
    return;
  }

  state.ctx = state.canvas.getContext('2d');
  if (!state.ctx) {
    alert('您的浏览器不支持Canvas,请升级浏览器');
    return;
  }

  // 初始化提示框
  state.tooltip = document.querySelector('.tooltip');

  // 初始化画布尺寸(高清适配)
  initCanvasSize();
  // 计算数据总和和最大值
  calcDataStats();
  // 绘制南丁格尔图
  drawNightingale();
  // 绘制图例
  drawLegend();
  // 绑定hover事件
  bindHoverEvent();
}

// 初始化画布尺寸(高清适配核心逻辑)
function initCanvasSize() {
  const { canvas, ctx } = state;
  const { DPR } = NIGHTINGALE_CONFIG;

  // 获取画布显示尺寸(从canvas属性读取)
  state.displayWidth = canvas.width;
  state.displayHeight = canvas.height;

  // 高清适配:实际像素尺寸 = 显示尺寸 × DPR
  state.canvasWidth = state.displayWidth * DPR;
  state.canvasHeight = state.displayHeight * DPR;

  // 设置画布样式和实际尺寸
  canvas.style.width = `${state.displayWidth}px`;
  canvas.style.height = `${state.displayHeight}px`;
  canvas.width = state.canvasWidth;
  canvas.height = state.canvasHeight;

  // 重置变换矩阵,避免重复缩放
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.scale(DPR, DPR);
}

// 计算数据总和和最大值(用于半径差异化计算)
function calcDataStats() {
  const { DATA } = NIGHTINGALE_CONFIG;
  state.totalValue = DATA.reduce((sum, item) => sum + item.value, 0);
  state.maxValue = Math.max(...DATA.map(item => item.value));
}

// 计算当前数据对应的半径(核心:南丁格尔图半径差异化)
function getSectorRadius(value) {
  const { MIN_RADIUS, MAX_RADIUS } = NIGHTINGALE_CONFIG;
  // 线性映射:value → [MIN_RADIUS, MAX_RADIUS]
  return MIN_RADIUS + (value / state.maxValue) * (MAX_RADIUS - MIN_RADIUS);
}

// 绘制南丁格尔图核心函数
function drawNightingale() {
  const { ctx } = state;
  const {
    CENTER_X, CENTER_Y, SHADOW_BLUR,
    SHADOW_COLOR, BORDER_WIDTH, BORDER_COLOR,
    DATA, LABEL
  } = NIGHTINGALE_CONFIG;

  let startAngle = -Math.PI / 2; // 起始角度(12点钟方向)

  ctx.save();
  // 统一设置阴影
  ctx.shadowOffsetX = 0;
  ctx.shadowOffsetY = 0;
  ctx.shadowBlur = SHADOW_BLUR;
  ctx.shadowColor = SHADOW_COLOR;

  // 遍历数据绘制每个扇区(半径差异化)
  DATA.forEach((item, index) => {
    const { value, color } = item;
    // 计算扇区角度
    const percentage = value / state.totalValue;
    const endAngle = startAngle + percentage * Math.PI * 2;
    // 计算当前扇区的半径(根据数值大小动态调整)
    const radius = getSectorRadius(value);

    // 绘制扇区
    ctx.beginPath();
    ctx.moveTo(CENTER_X, CENTER_Y); // 从中心开始
    ctx.arc(
      CENTER_X,
      CENTER_Y,
      radius,
      startAngle,
      endAngle
    );
    ctx.closePath();

    // 填充颜色(hover时加深颜色)
    ctx.fillStyle = state.hoveredIndex === index
      ? darkenColor(color, 0.1) // hover时加深10%
      : color;
    ctx.fill();

    // 绘制扇区边框(分隔效果)
    ctx.strokeStyle = BORDER_COLOR;
    ctx.lineWidth = BORDER_WIDTH;
    ctx.stroke();

    // 绘制扇区中心百分比标签
    drawSectorLabel(CENTER_X, CENTER_Y, radius, startAngle, endAngle, percentage);

    // 更新起始角度
    startAngle = endAngle;
  });

  ctx.restore();
}

// 绘制扇区百分比标签
function drawSectorLabel(centerX, centerY, radius, startAngle, endAngle, percentage) {
  const { ctx } = state;
  const { LABEL } = NIGHTINGALE_CONFIG;

  // 计算标签位置(基于当前扇区半径)
  const labelRadius = radius * LABEL.OFFSET;
  const midAngle = startAngle + (endAngle - startAngle) / 2;
  const labelX = centerX + Math.cos(midAngle) * labelRadius;
  const labelY = centerY + Math.sin(midAngle) * labelRadius;

  ctx.save();
  ctx.fillStyle = LABEL.COLOR;
  ctx.font = LABEL.FONT;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';

  // 绘制百分比(保留1位小数)
  const percentText = `${(percentage * 100).toFixed(1)}%`;
  ctx.fillText(percentText, labelX, labelY);

  ctx.restore();
}

// 绘制图例
function drawLegend() {
  const { ctx } = state;
  const { DATA, LEGEND } = NIGHTINGALE_CONFIG;
  const {
    X, Y, ITEM_WIDTH, ITEM_HEIGHT,
    GAP, TEXT_OFFSET, FONT, COLOR
  } = LEGEND;

  ctx.save();
  ctx.font = FONT;
  ctx.fillStyle = COLOR;
  ctx.textAlign = 'left';
  ctx.textBaseline = 'middle';

  // 遍历数据绘制每个图例项
  DATA.forEach((item, index) => {
    const { name, color, value } = item;
    // 计算当前图例项位置
    const legendY = Y + index * GAP;

    // 绘制图例色块(hover时同步加深)
    ctx.fillStyle = state.hoveredIndex === index
      ? darkenColor(color, 0.1)
      : color;
    ctx.fillRect(
      X,
      legendY - ITEM_HEIGHT / 2,
      ITEM_WIDTH,
      ITEM_HEIGHT
    );

    // 绘制图例文字(名称+数值+占比)
    const percentage = (item.value / state.totalValue * 100).toFixed(1);
    const legendText = `${name} (${value}, ${percentage}%)`;
    ctx.fillStyle = COLOR;
    ctx.fillText(
      legendText,
      X + ITEM_WIDTH + TEXT_OFFSET,
      legendY
    );
  });

  ctx.restore();
}

// 绑定hover事件(检测鼠标是否在扇区内)
function bindHoverEvent() {
  const { ctx, canvas } = state;
  const { CENTER_X, CENTER_Y, DATA } = NIGHTINGALE_CONFIG;

  canvas.addEventListener('mousemove', (e) => {
    // 获取鼠标在画布上的坐标(适配高清缩放)
    const rect = canvas.getBoundingClientRect();
    const mouseX = (e.clientX - rect.left) * (state.displayWidth / rect.width);
    const mouseY = (e.clientY - rect.top) * (state.displayHeight / rect.height);

    let hoveredIndex = -1;
    let startAngle = -Math.PI / 2;

    // 遍历每个扇区,判断鼠标是否在扇区内
    for (let i = 0; i < DATA.length; i++) {
      const item = DATA[i];
      const percentage = item.value / state.totalValue;
      const endAngle = startAngle + percentage * Math.PI * 2;
      const radius = getSectorRadius(item.value);

      // 判断鼠标是否在当前扇区内
      if (isPointInSector(mouseX, mouseY, CENTER_X, CENTER_Y, radius, startAngle, endAngle)) {
        hoveredIndex = i;
        break;
      }

      startAngle = endAngle;
    }

    // 如果hover索引变化,重新绘制并显示/隐藏提示框
    if (hoveredIndex !== state.hoveredIndex) {
      state.hoveredIndex = hoveredIndex;
      // 重新绘制图表(更新hover状态)
      ctx.clearRect(0, 0, state.displayWidth, state.displayHeight);
      drawNightingale();
      drawLegend();
    }

    // 显示提示框
    if (hoveredIndex !== -1) {
      const item = DATA[hoveredIndex];
      const percentage = (item.value / state.totalValue * 100).toFixed(1);
      state.tooltip.innerHTML = `
            <div>${item.name}</div>
            <div>数值:${item.value}</div>
            <div>占比:${percentage}%</div>
          `;
      state.tooltip.style.left = `${e.clientX + 10}px`;
      state.tooltip.style.top = `${e.clientY + 10}px`;
      state.tooltip.style.opacity = 1;
    } else {
      state.tooltip.style.opacity = 0;
    }
  });

  // 鼠标离开画布时隐藏提示框
  canvas.addEventListener('mouseleave', () => {
    state.hoveredIndex = -1;
    state.tooltip.style.opacity = 0;
    // 重新绘制图表(恢复默认状态)
    ctx.clearRect(0, 0, state.displayWidth, state.displayHeight);
    drawNightingale();
    drawLegend();
  });
}

// 工具函数:判断点是否在扇区内
function isPointInSector(px, py, cx, cy, radius, startAngle, endAngle) {
  // 计算点到中心的距离
  const dx = px - cx;
  const dy = py - cy;
  const distance = Math.sqrt(dx * dx + dy * dy);

  // 1. 距离超过半径 → 不在扇区内
  if (distance > radius) return false;

  // 2. 计算点的角度(相对于中心)
  let angle = Math.atan2(dy, dx); // 范围:[-π, π]
  if (angle < 0) angle += Math.PI * 2; // 转换为[0, 2π]

  // 3. 处理起始角度为负的情况(统一转换为[0, 2π])
  let start = startAngle < 0 ? startAngle + Math.PI * 2 : startAngle;
  let end = endAngle < 0 ? endAngle + Math.PI * 2 : endAngle;

  // 4. 判断角度是否在扇区角度范围内
  if (start <= end) {
    return angle >= start && angle <= end;
  } else {
    // 跨0度的情况(如:start=330°,end=30°)
    return angle >= start || angle <= end;
  }
}

// 工具函数:加深颜色(hover效果)
function darkenColor(color, ratio) {
  // 解析16进制颜色
  const hex = color.replace('#', '');
  const r = parseInt(hex.substr(0, 2), 16);
  const g = parseInt(hex.substr(2, 2), 16);
  const b = parseInt(hex.substr(4, 2), 16);

  // 加深颜色(每个通道乘以(1-ratio),不低于0)
  const darkR = Math.max(Math.floor(r * (1 - ratio)), 0);
  const darkG = Math.max(Math.floor(g * (1 - ratio)), 0);
  const darkB = Math.max(Math.floor(b * (1 - ratio)), 0);

  // 转换回16进制
  return `#${darkR.toString(16).padStart(2, '0')}${darkG.toString(16).padStart(2, '0')}${darkB.toString(16).padStart(2, '0')}`;
}

// DOM加载完成后初始化
document.addEventListener('DOMContentLoaded', initNightingaleChart);

碰撞检测

'use strict';

// 1. 配置项集中管理(便于灵活调整)
const CONFIG = Object.freeze({
  CANVAS_ID: 'canvas',
  BALL: {
    RADIUS: 40,          // 小球半径
    COLOR: '#FF9900',    // 小球颜色(更鲜艳的橙色)
    BORDER_WIDTH: 2,     // 边框宽度
    BORDER_COLOR: '#E68A00', // 边框颜色
    X_SPEED: 6,          // 水平初始速度
    Y_SPEED: 4,          // 垂直初始速度
    INIT_X: 100,         // 初始X坐标
    INIT_Y: 100          // 初始Y坐标
  },
  ANIMATION: {
    FPS: 60,             // 动画帧率(60帧更流畅)
    CLEAR_ALPHA: 0.15    // 轨迹透明值(0-1,0为无轨迹)
  },
  DPR: window.devicePixelRatio || 1 // 设备像素比(高清适配)
});

// 2. 全局状态管理(避免零散变量)
const state = {
  canvas: null,
  ctx: null,
  canvasWidth: 0,       // 画布实际像素宽度
  canvasHeight: 0,      // 画布实际像素高度
  displayWidth: 0,      // 画布显示宽度
  displayHeight: 0,     // 画布显示高度
  ballX: 0,             // 小球当前X坐标
  ballY: 0,             // 小球当前Y坐标
  ballXSpeed: 0,        // 小球水平速度
  ballYSpeed: 0,        // 小球垂直速度
  animationId: null     // 动画请求ID(用于暂停/取消)
};

// 3. 初始化入口函数(流程化)
function initCollisionDemo() {
  // 初始化DOM和上下文
  initCanvas();
  // 初始化小球状态
  initBallState();
  // 绑定窗口 resize 事件(响应式)
  bindResizeEvent();
  // 启动动画(使用requestAnimationFrame更流畅)
  startAnimation();
}

// 4. 初始化画布(高清适配+响应式)
function initCanvas() {
  state.canvas = document.getElementById(CONFIG.CANVAS_ID);
  if (!state.canvas) {
    console.error('未找到Canvas元素');
    return;
  }

  state.ctx = state.canvas.getContext('2d');
  if (!state.ctx) {
    alert('您的浏览器不支持Canvas,请升级浏览器');
    return;
  }

  // 设置画布尺寸(适配窗口大小)
  updateCanvasSize();

  // 高清适配:缩放上下文
  state.ctx.scale(CONFIG.DPR, CONFIG.DPR);
}

// 5. 更新画布尺寸(响应式核心)
function updateCanvasSize() {
  // 画布显示尺寸 = 窗口大小
  state.displayWidth = document.documentElement.clientWidth;
  state.displayHeight = document.documentElement.clientHeight;

  // 画布实际像素尺寸 = 显示尺寸 × DPR(高清适配)
  state.canvasWidth = state.displayWidth * CONFIG.DPR;
  state.canvasHeight = state.displayHeight * CONFIG.DPR;

  // 应用尺寸设置
  state.canvas.style.width = `${state.displayWidth}px`;
  state.canvas.style.height = `${state.displayHeight}px`;
  state.canvas.width = state.canvasWidth;
  state.canvas.height = state.canvasHeight;
}

// 6. 初始化小球状态
function initBallState() {
  const { BALL } = CONFIG;
  state.ballX = BALL.INIT_X;
  state.ballY = BALL.INIT_Y;
  state.ballXSpeed = BALL.X_SPEED;
  state.ballYSpeed = BALL.Y_SPEED;
}

// 7. 绘制小球(优化视觉效果)
function drawBall() {
  const { ctx } = state;
  const { RADIUS, COLOR, BORDER_WIDTH, BORDER_COLOR } = CONFIG.BALL;

  ctx.save();
  // 绘制小球主体(带渐变,更有立体感)
  const gradient = ctx.createRadialGradient(
    state.ballX, state.ballY, 0,
    state.ballX, state.ballY, RADIUS
  );
  gradient.addColorStop(0, '#FFF3E0'); // 高光色
  gradient.addColorStop(0.7, COLOR);   // 主体色
  gradient.addColorStop(1, BORDER_COLOR); // 阴影色

  ctx.beginPath();
  ctx.arc(state.ballX, state.ballY, RADIUS, 0, Math.PI * 2);
  ctx.fillStyle = gradient;
  ctx.fill();

  // 绘制边框
  ctx.strokeStyle = BORDER_COLOR;
  ctx.lineWidth = BORDER_WIDTH;
  ctx.stroke();

  ctx.restore();
}

// 8. 更新小球位置和碰撞检测
function updateBall() {
  const { RADIUS } = CONFIG.BALL;
  const { displayWidth, displayHeight } = state;

  // 边界碰撞检测(优化边界判断逻辑)
  // 左边界:小球左边缘 <= 0 → 反向+回弹修正
  if (state.ballX - RADIUS <= 0) {
    state.ballXSpeed = -state.ballXSpeed;
    state.ballX = RADIUS; // 避免小球卡边界
  }
  // 右边界:小球右边缘 >= 画布宽度 → 反向+回弹修正
  else if (state.ballX + RADIUS >= displayWidth) {
    state.ballXSpeed = -state.ballXSpeed;
    state.ballX = displayWidth - RADIUS; // 避免小球卡边界
  }

  // 上边界:小球上边缘 <= 0 → 反向+回弹修正
  if (state.ballY - RADIUS <= 0) {
    state.ballYSpeed = -state.ballYSpeed;
    state.ballY = RADIUS; // 避免小球卡边界
  }
  // 下边界:小球下边缘 >= 画布高度 → 反向+回弹修正
  else if (state.ballY + RADIUS >= displayHeight) {
    state.ballYSpeed = -state.ballYSpeed;
    state.ballY = displayHeight - RADIUS; // 避免小球卡边界
  }

  // 更新小球位置
  state.ballX += state.ballXSpeed;
  state.ballY += state.ballYSpeed;
}

// 9. 动画帧函数(使用requestAnimationFrame,性能更优)
function animate() {
  const { ctx } = state;
  const { CLEAR_ALPHA } = CONFIG.ANIMATION;

  // 清空画布(带透明效果,实现运动轨迹)
  ctx.fillStyle = `rgba(245, 245, 245, ${CLEAR_ALPHA})`;
  ctx.fillRect(0, 0, state.displayWidth, state.displayHeight);

  // 更新小球位置和碰撞检测
  updateBall();
  // 绘制小球
  drawBall();

  // 循环请求下一帧
  state.animationId = requestAnimationFrame(animate);
}

// 10. 启动动画
function startAnimation() {
  // 先取消之前的动画(避免重复)
  if (state.animationId) {
    cancelAnimationFrame(state.animationId);
  }
  // 启动新动画
  state.animationId = requestAnimationFrame(animate);
}

// 11. 绑定窗口resize事件(响应式)
function bindResizeEvent() {
  window.addEventListener('resize', () => {
    // 暂停动画
    cancelAnimationFrame(state.animationId);
    // 更新画布尺寸
    updateCanvasSize();
    // 重启动画
    startAnimation();
  });
}

// 12. 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', initCollisionDemo);

// 13. 暴露全局控制方法(可选,用于调试)
window.pauseAnimation = () => {
  cancelAnimationFrame(state.animationId);
};
window.resumeAnimation = () => {
  startAnimation();
};

弹性球

'use strict';

// 1. 配置项集中管理
const CONFIG = Object.freeze({
  CANVAS_ID: 'canvas',
  BALL: {
    MIN_RADIUS: 15,      // 小球最小半径
    MAX_RADIUS: 35,      // 小球最大半径
    MIN_SPEED: 2,        // 最小速度
    MAX_SPEED: 5,        // 最大速度
    BORDER_WIDTH: 2,     // 边框宽度
    BORDER_COLOR: '#333',// 边框颜色(统一深色,更协调)
    COLOR_POOL: [        // 颜色池(随机选择)
      '#FF6B6B', '#4ECDC4', '#45B7D1',
      '#96CEB4', '#FECA57', '#FF9FF3',
      '#54A0FF', '#5F27CD', '#00D2D3'
    ]
  },
  ANIMATION: {
    CLEAR_ALPHA: 0.12,   // 轨迹透明值(比单球略低,避免混乱)
  },
  DPR: window.devicePixelRatio || 1, // 高清适配
  INIT_BALL_COUNT: 8    // 初始小球数量
});

// 2. 全局状态管理
const state = {
  canvas: null,
  ctx: null,
  canvasWidth: 0,       // 画布实际像素宽度
  canvasHeight: 0,      // 画布实际像素高度
  displayWidth: 0,      // 画布显示宽度
  displayHeight: 0,     // 画布显示高度
  balls: [],            // 小球数组(存储所有小球实例)
  animationId: null     // 动画请求ID
};

// 3. 小球类(封装每个小球的属性和方法)
class Ball {
  constructor(x, y) {
    const { MIN_RADIUS, MAX_RADIUS, MIN_SPEED, MAX_SPEED, COLOR_POOL } = CONFIG.BALL;

    // 随机半径(在最小和最大之间)
    this.radius = Math.floor(Math.random() * (MAX_RADIUS - MIN_RADIUS)) + MIN_RADIUS;
    // 随机颜色(从颜色池选择)
    this.color = COLOR_POOL[Math.floor(Math.random() * COLOR_POOL.length)];
    // 随机位置(避免初始超出边界)
    this.x = x || Math.floor(Math.random() * (state.displayWidth - 2 * this.radius)) + this.radius;
    this.y = y || Math.floor(Math.random() * (state.displayHeight - 2 * this.radius)) + this.radius;
    // 随机速度(含方向:正负随机)
    this.xSpeed = (Math.random() * (MAX_SPEED - MIN_SPEED) + MIN_SPEED) * (Math.random() > 0.5 ? 1 : -1);
    this.ySpeed = (Math.random() * (MAX_SPEED - MIN_SPEED) + MIN_SPEED) * (Math.random() > 0.5 ? 1 : -1);
    // 边框样式
    this.borderWidth = CONFIG.BALL.BORDER_WIDTH;
    this.borderColor = CONFIG.BALL.BORDER_COLOR;
  }

  // 更新小球位置和边界碰撞检测
  update() {
    const { displayWidth, displayHeight } = state;

    // 水平边界碰撞
    if (this.x - this.radius <= 0) {
      this.x = this.radius; // 修正位置,避免卡边界
      this.xSpeed = -this.xSpeed; // 反向
    } else if (this.x + this.radius >= displayWidth) {
      this.x = displayWidth - this.radius;
      this.xSpeed = -this.xSpeed;
    }

    // 垂直边界碰撞
    if (this.y - this.radius <= 0) {
      this.y = this.radius;
      this.ySpeed = -this.ySpeed;
    } else if (this.y + this.radius >= displayHeight) {
      this.y = displayHeight - this.radius;
      this.ySpeed = -this.ySpeed;
    }

    // 更新位置
    this.x += this.xSpeed;
    this.y += this.ySpeed;
  }

  // 绘制小球(带渐变效果)
  draw() {
    const { ctx } = state;

    ctx.save();
    // 创建径向渐变(增强立体感)
    const gradient = ctx.createRadialGradient(
      this.x, this.y, 0,          // 渐变中心
      this.x, this.y, this.radius // 渐变边缘
    );
    gradient.addColorStop(0, '#fff'); // 高光(白色)
    gradient.addColorStop(0.6, this.color); // 主体色
    gradient.addColorStop(1, this.borderColor); // 阴影色

    // 绘制小球主体
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
    ctx.fillStyle = gradient;
    ctx.fill();

    // 绘制边框
    ctx.strokeStyle = this.borderColor;
    ctx.lineWidth = this.borderWidth;
    ctx.stroke();

    ctx.restore();
  }
}

// 4. 初始化入口函数
function initMultiBallCollision() {
  // 初始化画布
  initCanvas();
  // 初始化小球(默认生成多个)
  initBalls();
  // 绑定控制按钮事件
  bindControlEvents();
  // 绑定窗口resize事件
  bindResizeEvent();
  // 启动动画
  startAnimation();
}

// 5. 初始化画布
function initCanvas() {
  state.canvas = document.getElementById(CONFIG.CANVAS_ID);
  if (!state.canvas) {
    console.error('未找到Canvas元素');
    return;
  }

  state.ctx = state.canvas.getContext('2d');
  if (!state.ctx) {
    alert('您的浏览器不支持Canvas,请升级浏览器');
    return;
  }

  // 更新画布尺寸
  updateCanvasSize();

  // 高清适配:缩放上下文
  state.ctx.scale(CONFIG.DPR, CONFIG.DPR);
}

// 6. 更新画布尺寸(响应式)
function updateCanvasSize() {
  state.displayWidth = document.documentElement.clientWidth;
  state.displayHeight = document.documentElement.clientHeight;
  state.canvasWidth = state.displayWidth * CONFIG.DPR;
  state.canvasHeight = state.displayHeight * CONFIG.DPR;

  // 应用尺寸
  state.canvas.style.width = `${state.displayWidth}px`;
  state.canvas.style.height = `${state.displayHeight}px`;
  state.canvas.width = state.canvasWidth;
  state.canvas.height = state.canvasHeight;
}

// 7. 初始化小球(默认生成指定数量)
function initBalls() {
  state.balls = []; // 清空现有小球
  for (let i = 0; i < CONFIG.INIT_BALL_COUNT; i++) {
    state.balls.push(new Ball());
  }
  // 更新小球计数显示
  updateBallCount();
}

// 8. 添加单个小球(支持鼠标点击位置生成)
function addSingleBall(x = null, y = null) {
  state.balls.push(new Ball(x, y));
  updateBallCount();
}

// 9. 清空所有小球
function clearAllBalls() {
  state.balls = [];
  updateBallCount();
}

// 10. 更新小球计数显示
function updateBallCount() {
  document.getElementById('ballCount').textContent = state.balls.length;
}

// 11. 动画帧函数
function animate() {
  const { ctx } = state;
  const { CLEAR_ALPHA } = CONFIG.ANIMATION;

  // 清空画布(带轨迹效果)
  ctx.fillStyle = `rgba(245, 245, 245, ${CLEAR_ALPHA})`;
  ctx.fillRect(0, 0, state.displayWidth, state.displayHeight);

  // 遍历所有小球:更新位置 + 绘制
  state.balls.forEach(ball => {
    ball.update();
    ball.draw();
  });

  // 循环请求下一帧
  state.animationId = requestAnimationFrame(animate);
}

// 12. 启动动画
function startAnimation() {
  if (state.animationId) {
    cancelAnimationFrame(state.animationId);
  }
  state.animationId = requestAnimationFrame(animate);
}

// 13. 绑定控制按钮事件
function bindControlEvents() {
  // 添加小球按钮
  document.getElementById('addBall').addEventListener('click', () => {
    addSingleBall();
  });

  // 清空小球按钮
  document.getElementById('clearBall').addEventListener('click', () => {
    clearAllBalls();
  });

  // 鼠标点击画布添加小球
  state.canvas.addEventListener('click', (e) => {
    const rect = state.canvas.getBoundingClientRect();
    // 转换鼠标坐标为画布坐标(适配高清缩放)
    const x = (e.clientX - rect.left) * (state.displayWidth / rect.width);
    const y = (e.clientY - rect.top) * (state.displayHeight / rect.height);
    addSingleBall(x, y);
  });
}

// 14. 绑定窗口resize事件
function bindResizeEvent() {
  window.addEventListener('resize', () => {
    cancelAnimationFrame(state.animationId);
    updateCanvasSize();
    startAnimation();
  });
}

// 15. 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', initMultiBallCollision);

// 16. 全局控制方法(可选)
window.pauseAnimation = () => {
  cancelAnimationFrame(state.animationId);
};
window.resumeAnimation = () => {
  startAnimation();
};

绘制关系图

'use strict';

// 1. 配置项集中管理(灵活调整效果)
const CONFIG = Object.freeze({
  CANVAS_ID: 'canvas',
  NODE: {
    MIN_RADIUS: 20,      // 节点最小半径
    MAX_RADIUS: 35,      // 节点最大半径
    MIN_SPEED: 1.5,      // 最小运动速度
    MAX_SPEED: 3,        // 最大运动速度
    TEXT_FONT: '16px Microsoft YaHei', // 节点文字字体
    TEXT_COLOR: '#fff',  // 节点文字颜色
    TEXT_OFFSET: 25,     // 文字与节点的距离
    BORDER_WIDTH: 2,     // 节点边框宽度
    BORDER_COLOR: '#fff',// 节点边框颜色
    HOVER_SCALE: 1.2,    // 悬浮缩放比例
    COLOR_POOL: [        // 节点颜色池(协调的渐变色系)
      '#4361EE', '#3A0CA3', '#7209B7',
      '#F72585', '#4CC9F0', '#4361EE',
      '#560BAD', '#F8961E', '#90A959'
    ]
  },
  LINK: {
    LINE_WIDTH: 1.2,     // 连线宽度
    MIN_OPACITY: 0.3,    // 连线最小透明度
    MAX_OPACITY: 0.7,    // 连线最大透明度
    DISTANCE_FACTOR: 300 // 距离衰减因子(距离越远越透明)
  },
  ANIMATION: {
    FPS: 60,             // 动画帧率
    CLEAR_ALPHA: 0.15    // 轨迹残留透明度(营造流动感)
  },
  DATA: [               // 关系图数据(可扩展)
    { id: 1, name: 'Vue' },
    { id: 2, name: 'Webpack' },
    { id: 3, name: 'React' },
    { id: 4, name: 'Angular' },
    { id: 5, name: 'Python' },
    { id: 6, name: 'Nodejs' },
    { id: 7, name: 'eCharts' },
    { id: 8, name: 'Next' }
  ],
  DPR: window.devicePixelRatio || 1 // 高清适配
});

// 2. 全局状态管理
const state = {
  canvas: null,
  ctx: null,
  canvasWidth: 0,       // 画布实际像素宽度
  canvasHeight: 0,      // 画布实际像素高度
  displayWidth: 0,      // 画布显示宽度
  displayHeight: 0,     // 画布显示高度
  nodes: [],            // 节点数组
  hoveredNodeId: null,  // 悬浮节点ID
  tooltip: null,        // 提示框元素
  animationId: null     // 动画请求ID
};

// 3. 节点类(封装节点属性和方法)
class Node {
  constructor(data) {
    const { MIN_RADIUS, MAX_RADIUS, MIN_SPEED, MAX_SPEED, COLOR_POOL } = CONFIG.NODE;
    const { displayWidth, displayHeight } = state;

    // 基础属性
    this.id = data.id;
    this.name = data.name;
    this.radius = Math.floor(Math.random() * (MAX_RADIUS - MIN_RADIUS)) + MIN_RADIUS;
    this.baseRadius = this.radius; // 原始半径(用于悬浮缩放)
    this.color = COLOR_POOL[Math.floor(Math.random() * COLOR_POOL.length)];

    // 初始位置(避免超出边界)
    this.x = Math.random() * (displayWidth - 2 * this.radius) + this.radius;
    this.y = Math.random() * (displayHeight - 2 * this.radius) + this.radius;

    // 运动速度(随机方向)
    this.xSpeed = (Math.random() * (MAX_SPEED - MIN_SPEED) + MIN_SPEED) * (Math.random() > 0.5 ? 1 : -1);
    this.ySpeed = (Math.random() * (MAX_SPEED - MIN_SPEED) + MIN_SPEED) * (Math.random() > 0.5 ? 1 : -1);

    // 样式属性
    this.borderWidth = CONFIG.NODE.BORDER_WIDTH;
    this.borderColor = CONFIG.NODE.BORDER_COLOR;
  }

  // 更新节点位置和边界碰撞
  update() {
    const { displayWidth, displayHeight } = state;
    const { HOVER_SCALE } = CONFIG.NODE;

    // 边界碰撞检测(带回弹修正)
    if (this.x - this.radius <= 0) {
      this.x = this.radius;
      this.xSpeed = -this.xSpeed * 0.9; // 速度衰减,更自然
    } else if (this.x + this.radius >= displayWidth) {
      this.x = displayWidth - this.radius;
      this.xSpeed = -this.xSpeed * 0.9;
    }

    if (this.y - this.radius <= 0) {
      this.y = this.radius;
      this.ySpeed = -this.ySpeed * 0.9;
    } else if (this.y + this.radius >= displayHeight) {
      this.y = displayHeight - this.radius;
      this.ySpeed = -this.ySpeed * 0.9;
    }

    // 悬浮缩放效果
    this.radius = state.hoveredNodeId === this.id
      ? this.baseRadius * HOVER_SCALE
      : this.baseRadius;

    // 更新位置
    this.x += this.xSpeed;
    this.y += this.ySpeed;
  }

  // 绘制节点(带渐变和边框)
  draw() {
    const { ctx } = state;
    const { TEXT_FONT, TEXT_COLOR, TEXT_OFFSET, BORDER_WIDTH, BORDER_COLOR } = CONFIG.NODE;

    ctx.save();

    // 绘制节点主体(径向渐变,增强立体感)
    const gradient = ctx.createRadialGradient(
      this.x, this.y, 0,
      this.x, this.y, this.radius
    );
    gradient.addColorStop(0, this.lightenColor(this.color, 0.3)); // 高光
    gradient.addColorStop(0.7, this.color);                       // 主体色
    gradient.addColorStop(1, this.darkenColor(this.color, 0.2));  // 阴影

    ctx.beginPath();
    ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
    ctx.fillStyle = gradient;
    ctx.fill();

    // 绘制节点边框
    ctx.strokeStyle = BORDER_COLOR;
    ctx.lineWidth = BORDER_WIDTH;
    ctx.stroke();

    // 绘制节点文字(在节点下方)
    ctx.font = TEXT_FONT;
    ctx.fillStyle = TEXT_COLOR;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(this.name, this.x, this.y + this.radius + TEXT_OFFSET);

    ctx.restore();
  }

  // 绘制节点间连线(带距离衰减透明度)
  drawLink(targetNode) {
    const { ctx } = state;
    const { LINE_WIDTH, MIN_OPACITY, MAX_OPACITY, DISTANCE_FACTOR } = CONFIG.LINK;

    // 计算两节点距离
    const distance = Math.sqrt(
      Math.pow(this.x - targetNode.x, 2) + Math.pow(this.y - targetNode.y, 2)
    );

    // 距离衰减:距离越远,透明度越低
    const opacity = Math.max(
      MIN_OPACITY,
      MAX_OPACITY - (distance / DISTANCE_FACTOR)
    );

    ctx.save();
    ctx.beginPath();
    ctx.lineWidth = LINE_WIDTH;
    // 连线颜色取当前节点颜色,带透明度
    ctx.strokeStyle = `${this.color}${Math.floor(opacity * 255).toString(16).padStart(2, '0')}`;
    ctx.moveTo(this.x, this.y);
    ctx.lineTo(targetNode.x, targetNode.y);
    // 贝塞尔曲线优化连线(更平滑)
    const cpX = (this.x + targetNode.x) / 2;
    const cpY = (this.y + targetNode.y) / 2 - 20;
    ctx.quadraticCurveTo(cpX, cpY, targetNode.x, targetNode.y);
    ctx.stroke();
    ctx.restore();
  }

  // 工具函数:提亮颜色
  lightenColor(color, ratio) {
    const hex = color.replace('#', '');
    const r = Math.min(255, Math.floor(parseInt(hex.substr(0, 2), 16) * (1 + ratio)));
    const g = Math.min(255, Math.floor(parseInt(hex.substr(2, 2), 16) * (1 + ratio)));
    const b = Math.min(255, Math.floor(parseInt(hex.substr(4, 2), 16) * (1 + ratio)));
    return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
  }

  // 工具函数:加深颜色
  darkenColor(color, ratio) {
    const hex = color.replace('#', '');
    const r = Math.max(0, Math.floor(parseInt(hex.substr(0, 2), 16) * (1 - ratio)));
    const g = Math.max(0, Math.floor(parseInt(hex.substr(2, 2), 16) * (1 - ratio)));
    const b = Math.max(0, Math.floor(parseInt(hex.substr(4, 2), 16) * (1 - ratio)));
    return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
  }

  // 判断鼠标是否在节点内
  isPointInNode(mouseX, mouseY) {
    const distance = Math.sqrt(
      Math.pow(mouseX - this.x, 2) + Math.pow(mouseY - this.y, 2)
    );
    return distance <= this.radius;
  }
}

// 4. 初始化入口函数
function initRelationshipGraph() {
  // 初始化DOM和上下文
  initCanvas();
  // 初始化提示框
  initTooltip();
  // 创建节点
  createNodes();
  // 绑定交互事件
  bindEvents();
  // 启动动画
  startAnimation();
}

// 5. 初始化画布(高清适配+响应式)
function initCanvas() {
  state.canvas = document.getElementById(CONFIG.CANVAS_ID);
  if (!state.canvas) {
    console.error('未找到Canvas元素');
    return;
  }

  state.ctx = state.canvas.getContext('2d');
  if (!state.ctx) {
    alert('您的浏览器不支持Canvas,请升级浏览器');
    return;
  }

  // 更新画布尺寸
  updateCanvasSize();

  // 高清适配:缩放上下文
  state.ctx.scale(CONFIG.DPR, CONFIG.DPR);
}

// 6. 更新画布尺寸(响应式)
function updateCanvasSize() {
  state.displayWidth = document.documentElement.clientWidth;
  state.displayHeight = document.documentElement.clientHeight;
  state.canvasWidth = state.displayWidth * CONFIG.DPR;
  state.canvasHeight = state.displayHeight * CONFIG.DPR;

  // 应用尺寸设置
  state.canvas.style.width = `${state.displayWidth}px`;
  state.canvas.style.height = `${state.displayHeight}px`;
  state.canvas.width = state.canvasWidth;
  state.canvas.height = state.canvasHeight;
}

// 7. 初始化提示框
function initTooltip() {
  state.tooltip = document.querySelector('.tooltip');
}

// 8. 创建所有节点
function createNodes() {
  state.nodes = CONFIG.DATA.map(data => new Node(data));
}

// 9. 动画帧函数(使用requestAnimationFrame,性能更优)
function animate() {
  const { ctx } = state;
  const { CLEAR_ALPHA } = CONFIG.ANIMATION;

  // 清空画布(带轨迹残留,营造流动感)
  ctx.fillStyle = `rgba(26, 26, 46, ${CLEAR_ALPHA})`;
  ctx.fillRect(0, 0, state.displayWidth, state.displayHeight);

  // 1. 绘制所有连线(先画连线,节点在上方)
  state.nodes.forEach((node, index) => {
    // 只绘制与后续节点的连线,避免重复
    for (let i = index + 1; i < state.nodes.length; i++) {
      node.drawLink(state.nodes[i]);
    }
  });

  // 2. 更新并绘制所有节点
  state.nodes.forEach(node => {
    node.update();
    node.draw();
  });

  // 循环请求下一帧
  state.animationId = requestAnimationFrame(animate);
}

// 10. 启动动画
function startAnimation() {
  if (state.animationId) {
    cancelAnimationFrame(state.animationId);
  }
  state.animationId = requestAnimationFrame(animate);
}

// 11. 绑定交互事件(悬浮、点击、窗口缩放)
function bindEvents() {
  // 鼠标移动:检测悬浮节点
  state.canvas.addEventListener('mousemove', (e) => {
    const rect = state.canvas.getBoundingClientRect();
    // 转换鼠标坐标为画布坐标(适配高清缩放)
    const mouseX = (e.clientX - rect.left) * (state.displayWidth / rect.width);
    const mouseY = (e.clientY - rect.top) * (state.displayHeight / rect.height);

    let hoveredNode = null;
    // 检测是否悬浮在某个节点上
    state.nodes.forEach(node => {
      if (node.isPointInNode(mouseX, mouseY)) {
        hoveredNode = node;
      }
    });

    // 更新悬浮状态
    if (hoveredNode) {
      state.hoveredNodeId = hoveredNode.id;
      // 显示提示框
      state.tooltip.innerHTML = `<div>${hoveredNode.name}</div>`;
      state.tooltip.style.left = `${e.clientX + 10}px`;
      state.tooltip.style.top = `${e.clientY + 10}px`;
      state.tooltip.style.opacity = 1;
      // 鼠标样式改为指针
      state.canvas.style.cursor = 'pointer';
    } else {
      state.hoveredNodeId = null;
      // 隐藏提示框
      state.tooltip.style.opacity = 0;
      // 恢复默认鼠标样式
      state.canvas.style.cursor = 'default';
    }
  });

  // 鼠标离开画布:重置悬浮状态
  state.canvas.addEventListener('mouseleave', () => {
    state.hoveredNodeId = null;
    state.tooltip.style.opacity = 0;
    state.canvas.style.cursor = 'default';
  });

  // 窗口缩放:更新画布尺寸
  window.addEventListener('resize', () => {
    cancelAnimationFrame(state.animationId);
    updateCanvasSize();
    startAnimation();
  });
}

// 12. 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', initRelationshipGraph);

// 13. 全局控制方法(可选,用于调试)
window.pauseAnimation = () => {
  cancelAnimationFrame(state.animationId);
};
window.resumeAnimation = () => {
  startAnimation();
};
打赏作者
您的打赏是我前进的动力
微信
支付宝
评论

你好👏🏻,我是 ✍🏻   疯狂 codding 中...

粽子

这有关于前端开发的技术文档和你分享。

相信你可以在这里找到对你有用的知识和教程。

了解更多

目录

  1. 1. 初识 canvas
    1. 1.1. 什么是 canvas ?
    2. 1.2. 渲染上下文
    3. 1.3. 使用 canvas 绘制图片或者是文字在 Retina 屏中会非常模糊
  2. 2. canvas 绘制矩形、路径、曲线、文本、阴影
    1. 2.1. 绘制矩形
    2. 2.2. 绘制路径
      1. 2.2.1. 案例一
      2. 2.2.2. 案例二
    3. 2.3. 绘制曲线
      1. 2.3.1. 案例:绘制曲线
    4. 2.4. 绘制文字
      1. 2.4.1. 案例
  3. 3. canvas 图形转换、渐变、合成
    1. 3.1. 图形转换
      1. 3.1.1. 案例:移动
      2. 3.1.2. 案例:旋转
      3. 3.1.3. 案例:缩放
    2. 3.2. 渐变
      1. 3.2.1. 案例:线性渐变
      2. 3.2.2. 案例:径向渐变
    3. 3.3. 合成
      1. 3.3.1. 案例:全局透明度
      2. 3.3.2. 案例:覆盖合成
  4. 4. canvas 使用图片、像素操作
    1. 4.1. 使用图片
      1. 4.1.1. 案例:插入图片
      2. 4.1.2. 案例:设置背景
    2. 4.2. 像素操作
      1. 4.2.1. 获取 ImageData 对象
      2. 4.2.2. 创建 ImageData 对象
  5. 5. canvas 导出、事件
    1. 5.1. 案例:将画布导出为图像
    2. 5.2. 案例:事件操作
  6. 6. canvas 案例
    1. 6.1. 缩放
    2. 6.2. 表盘
    3. 6.3. 马赛克
    4. 6.4. 绘制直角坐标系
    5. 6.5. 绘制直方图
    6. 6.6. 绘制饼图
    7. 6.7. 碰撞检测
    8. 6.8. 弹性球
    9. 6.9. 绘制关系图