阅读视图

发现新文章,点击刷新页面。

从零实现2D绘图引擎:6.动画系统的实现

MiniRender仓库地址参考

动画系统 (Animation System)

这是让静态图表变成“活”图表的关键。我们的目标不是写死 requestAnimationFrame,而是构建一个声明式的动画库,让开发者只需要告诉引擎“我想去哪里”,引擎自动负责“怎么去”。


1. 核心模块设计

我们需要三个新文件:

  1. Easing.ts: 缓动函数库(提供 linear, cubicOut 等数学公式)。
  2. Animator.ts: 动画执行者(负责单个对象的属性插值计算)。
  3. Animation.ts: 动画管理器(负责全局的时间循环 Loop,调度所有 Animator)。

同时,我们需要修改 ElementMiniRender 类来集成这个系统。


2. 缓动函数 (src/animation/Easing.ts)

缓动函数输入一个 01 的时间进度 t,输出一个变换后的进度 p

// src/animation/Easing.ts

type EasingFunc = (t: number) => number;

export const Easing = {
    linear: (t: number) => t,
    
    // 二次缓动
    quadraticIn: (t: number) => t * t,
    quadraticOut: (t: number) => t * (2 - t),
    
    // 三次缓动 (常用,自然)
    cubicIn: (t: number) => t * t * t,
    cubicOut: (t: number) => --t * t * t + 1,
    
    // 弹性缓动
    elasticOut: (t: number) => {
        const c4 = (2 * Math.PI) / 3;
        return t === 0 ? 0 : t === 1 ? 1 :
            Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
    }
};

export type EasingType = keyof typeof Easing;

3. 动画执行者 (src/animation/Animator.ts)

这是最复杂的部分。它需要能够深度遍历对象,找出数值属性,并在起始值和目标值之间进行插值。

// src/animation/Animator.ts
import { Easing, EasingType } from './Easing';

export class Animator {
    target: any;
    
    private _startState: any = {};
    private _endState: any;
    
    private _duration: number;
    private _easing: EasingType;
    
    private _startTime: number = 0;
    private _delay: number = 0;
    
    private _onUpdate?: () => void;
    private _onDone?: () => void;
    
    // 标记动画是否已结束
    isFinished: boolean = false;

    constructor(target: any, endState: any, duration: number, easing: EasingType = 'linear') {
        this.target = target;
        this._endState = endState;
        this._duration = duration;
        this._easing = easing;
    }

    start(time: number) {
        this._startTime = time + this._delay;
        // 核心:在开始瞬间,克隆当前状态作为起始状态
        this._startState = this._cloneState(this._endState, this.target);
    }

    /**
     * 每一帧调用此方法
     * @param globalTime 全局时间戳
     * @return boolean 是否有变化
     */
    step(globalTime: number): boolean {
        if (this.isFinished) return false;
        if (globalTime < this._startTime) return false; // 还没到 delay 时间

        // 1. 计算进度 (0 ~ 1)
        let p = (globalTime - this._startTime) / this._duration;
        if (p >= 1) {
            p = 1;
            this.isFinished = true;
        }

        // 2. 应用缓动
        const easingFunc = Easing[this._easing] || Easing.linear;
        const v = easingFunc(p);

        // 3. 执行插值
        this._interpolate(this.target, this._startState, this._endState, v);

        // 4. 回调
        if (this._onUpdate) this._onUpdate();
        if (this.isFinished && this._onDone) this._onDone();

        return true;
    }
    
    // API: 设置延迟
    delay(ms: number) {
        this._delay = ms;
        return this;
    }
    
    // API: 完成回调
    done(cb: () => void) {
        this._onDone = cb;
        return this;
    }
    
    // API: 更新回调
    update(cb: () => void) {
        this._onUpdate = cb;
        return this;
    }

    // --- 内部辅助方法 ---

    // 递归插值:将 start 到 end 的值设置给 target
    private _interpolate(target: any, start: any, end: any, p: number) {
        for (const key in end) {
            const sVal = start[key];
            const eVal = end[key];

            if (typeof eVal === 'number' && typeof sVal === 'number') {
                // 数字:直接计算
                target[key] = sVal + (eVal - sVal) * p;
            } else if (Array.isArray(eVal) && Array.isArray(sVal)) {
                // 数组:递归处理 (如 position: [x, y])
                if (!target[key]) target[key] = [];
                this._interpolate(target[key], sVal, eVal, p);
            } else if (typeof eVal === 'object' && eVal !== null) {
                // 对象:递归处理 (如 style: { ... })
                if (!target[key]) target[key] = {};
                this._interpolate(target[key], sVal, eVal, p);
            }
            // 颜色插值比较复杂(涉及字符串解析),Stage 4 暂不实现,直接跳变
        }
    }

    // 递归克隆状态:只克隆 endState 中有的属性
    private _cloneState(end: any, source: any) {
        const res: any = {};
        for (const key in end) {
            const val = source[key];
            if (typeof end[key] === 'object' && end[key] !== null && !Array.isArray(end[key])) {
                // 递归对象
                res[key] = this._cloneState(end[key], val);
            } else if (Array.isArray(end[key])) {
                // 拷贝数组
                res[key] = Array.from(val || []);
            } else {
                // 基本类型
                res[key] = val;
            }
        }
        return res;
    }
}

4. 动画管理器 (src/animation/Animation.ts)

这是一个单例或者依附于 MiniRender 的管理器,负责驱动 requestAnimationFrame

// src/animation/Animation.ts
import { Animator } from './Animator';

export class Animation {
    private _animators: Animator[] = [];
    private _isRunning: boolean = false;

    // 注入 MiniRender 的刷新方法
    onFrame?: () => void;

    add(animator: Animator) {
        this._animators.push(animator);
        // 自动启动 Animator
        animator.start(Date.now());
        
        if (!this._isRunning) {
            this._startLoop();
        }
    }

    private _startLoop() {
        this._isRunning = true;

        const step = () => {
            const time = Date.now();
            let hasChange = false;

            // 倒序遍历,方便删除
            for (let i = this._animators.length - 1; i >= 0; i--) {
                const anim = this._animators[i];
                const changed = anim.step(time);
                if (changed) hasChange = true;

                // 移除已完成的动画
                if (anim.isFinished) {
                    this._animators.splice(i, 1);
                }
            }

            // 如果有属性变化,触发外部重绘
            if (hasChange && this.onFrame) {
                this.onFrame();
            }

            if (this._animators.length > 0) {
                requestAnimationFrame(step);
            } else {
                this._isRunning = false;
            }
        };

        requestAnimationFrame(step);
    }
}

5. 集成到核心系统

A. 修改 src/core/MiniRender.ts

初始化 Animation 模块,并建立“动画 -> 重绘”的桥梁。

import { Animation } from '../animation/Animation';
// ...

export class MiniRender {
    // ...
    animation: Animation;

    constructor(dom: HTMLElement) {
        // ...
        this.animation = new Animation();
        
        // 当动画产生帧更新时,调用 painter 刷新
        this.animation.onFrame = () => {
            this.painter.refresh();
        };
    }

    addAnimator(animator: any) {
        this.animation.add(animator);
    }
    // ...
}

B. 修改 src/graphic/Element.ts

给所有元素添加便捷 API animateTo

注意:我们需要一种方式让 Element 能访问到 MiniRender 实例或者 Animation 全局实例。为了解耦,MiniRender 在添加 Element 时,可以给 Element 注入一个 miniRender 引用,或者我们简单点,让 animateTo 返回一个 Animator 对象,由用户手动 miniRender.animation.add(),或者实现一个更高级的调度。

我们需要修改 Element.ts、Group.ts 和 MiniRender.ts 三个文件。

1). 修改 src/graphic/Element.ts

给 Element 增加一个 miniRender 属性。并在 animateTo 中检查这个属性。

// src/graphic/Element.ts
import { Animator } from '../animation/Animator';
import { EasingType } from '../animation/Easing';

export abstract class Element extends Eventful {
    // ... 原有属性 ...

    // 新增:持有对 MiniRender 实例的引用
    // 使用 any 是为了避免循环引用类型问题 (Element <-> MiniRender)
    miniRender: any = null; 

    animateTo(targetState: any, duration: number, easing: EasingType = 'linear', delay: number = 0) {
        const animator = new Animator(this, targetState, duration, easing);
        if (delay > 0) animator.delay(delay);
        
        this.animators.push(animator);

        if (this.miniRender) {
            this.miniRender.animation.add(animator);
        }

        return animator; // 依然返回,以便链式调用 .done() 等
    }
}
2). 定义遍历辅助函数

我们需要一个递归函数,当一个 Group 被加入时,把它底下所有的子孙节点的 miniRender 属性都设置好。为了避免复杂的循环依赖,我们可以把这个函数放在 src/utils/zrenderHelper.ts 或者直接作为 Element 的一个简单方法,这里我们修改 Group.ts 和 MiniRender.ts 来配合。

我们采用最简单的方式:在 添加子节点 时进行传递。

3).修改 src/graphic/Group.ts

当向 Group 添加子节点时,如果 Group 已经有了 miniRender,就传给子节点。

// src/graphic/Group.ts
import { Element } from './Element';

export class Group extends Element {
    // ...

    add(child: Element) {
        if (child && child !== this && child.parent !== this) {
            this.children.push(child);
            child.parent = this;

            if (this.miniRender) {
                // 递归设置子树
                this._propagateRender(child, this.miniRender);
            }
        }
    }

    /**
     * 辅助方法:递归向下传递 miniRender 引用
     */
    private _propagateRender(el: Element, miniRender: any) {
        el.miniRender = miniRender;
        if ((el as Group).isGroup) {
            const children = (el as Group).children;
            for (let i = 0; i < children.length; i++) {
                this._propagateRender(children[i], miniRender);
            }
        }
    }
}
4).修改 src/core/MiniRender.ts

这是入口。当用户调用 miniRender.add(el) 时,注入依赖。

// src/core/MiniRender.ts

export class MiniRender {
    // ...

    add(el: Element) {
        // 关键修改:根节点注入
        this._propagateRender(el, this);
        
        this.storage.addRoot(el);
        this.refresh();
    }

    // 复制 Group 中的那个辅助逻辑,或者提取成公共函数
    // 这里简单拷贝一份逻辑确保根节点也能递归
    private _propagateRender(el: Element, miniRender: any) {
        el.miniRender = miniRender;
        if ((el as any).isGroup) {
            const children = (el as any).children;
            for (let i = 0; i < children.length; i++) {
                this._propagateRender(children[i], miniRender);
            }
        }
    }
    
    // addAnimator 方法现在是给内部用的,或者作为高级 API 保留
    addAnimator(animator: any) {
        this.animation.add(animator);
    }
}

6. Demo

我们要修改之前的柱状图代码,让柱子在创建时高度为 0,然后长高。

逻辑分析

  1. 初始状态:
    • height: 0
    • y: chartHeight (即 X 轴的位置)
  2. 目标状态:
    • height: barHeight (真实高度)
    • y: chartHeight - barHeight (真实 Y 坐标)

修改 index.ts:

// ... 前面的代码不变

data.forEach((value, index) => {
    // ... 计算 barHeight, finalY 等 ...

    const bar = new Rect({
        shape: {
            x: finalX,
            y: chartConfig.height,
            width: chartConfig.barWidth,
            height: 0
        },
        style: { fill: chartConfig.barColor }
    });

    // 先 add 到 Group (此时 Group 还没加到 miniRender,所以 bar.miniRender 还是 null)
    chartGroup.add(bar);
    chartGroup.add(label);
    
    // 方式 1: 延迟动画
    // 因为 chartGroup 还没加到 miniRender,所以这里直接调 animateTo 不会立即启动
    // 但是!我们在 chartGroup 加到 miniRender 后,bar.miniRender 会被赋值。
    // 可是 animator 已经在 create 时判断过 miniRender 是 null 了,没有加进去。
    // 【修正逻辑】:
    // 我们的 animateTo 是“创建即启动”。
    // 如果 element 还没加到 miniRender,调用 animateTo 会创建 Animator 但不会加入 Animation Loop。
    // 这会导致动画“丢失”。
    
    // 为了解决这个问题,通常有两种写法:
    // 1. 先把 chartGroup 加到 miniRender,再创建图形和动画 (推荐)。
    // 2. Element 内部做一个 pendingAnimators 队列,当 miniRender 被赋值时自动 add (实现较复杂)。
    
    // 这里我们采用写法 1 (ZRender 的标准用法也是先 add 再 animate)。
});

// 1. 先把组加到引擎中!(此时所有 children 的 .miniRender 都会被赋值)
miniRender.add(chartGroup); 

// 2. 再遍历数据创建动画 (或者在创建 bar 时就 animate,前提是 bar 已经有 miniRender)
// 这里我们需要稍微调整代码顺序,或者在上面的 forEach 里改一下逻辑:

// === 最佳实践代码顺序 ===

// 1. 准备 Group
const chartGroup = new Group({ position: [chartConfig.x, chartConfig.y] });
miniRender.add(chartGroup); // <--- 先把 Group 挂载上去!

data.forEach((value, index) => {
    // ... 坐标计算 ...

    const bar = new Rect({ /*...*/ });
    
    // 2. bar 加入 chartGroup
    // 因为 chartGroup 已经在 miniRender 里了,bar 加入瞬间,Group.add 会把 miniRender 传给 bar
    chartGroup.add(bar); 
    
    // 3. 此时 bar.miniRender 已经有值了,直接开启动画!
    bar.animateTo(
        {
            shape: {
                y: finalY,
                height: finalHeight
            }
        },
        1000,
        'cubicOut',
        index * 100
    );
    // 不需要 miniRender.addAnimator(animator) 了!
});

animation转存失败,建议直接上传图片文件

7.阶段总结

我们已经从零构建了一个具备对象模型、渲染管线、交互系统、动画引擎的 Canvas 2D 引擎微内核(MiniRender)。它已经时一个具备扩展性的图形库雏形了。

我们目前的 MiniRender 已经实现了 ZRender v4/v5 的核心设计思想:

模块 类名 (Class) 核心职责 当前状态
入口 MiniRender 外观模式入口,协调各模块,管理主循环。 ✅ 已实现依赖注入
数据 Storage 场景图 (Scene Graph) 管理,维护显示列表,处理层级排序。 ✅ 支持 Group/Z-index
渲染 Painter 视图 (View),负责 Canvas 上下文管理、重绘循环 (refresh)。 ✅ 基础全量重绘
图形 Element / Displayable 节点 (Node),实现仿射变换矩阵 (transform)、父子级联。 ✅ 矩阵运算/样式封装
形状 Rect / Circle / Text 具体图形几何定义与包含检测 (contain)。 ✅ 基础形状
交互 Handler 控制器 (Controller),实现坐标逆变换,DOM 事件代理与分发。 ✅ Click/Hover/Silent
动画 Animation / Animator 声明式动画系统,支持缓动函数与属性插值。 ✅ 支持递归插值

从零实现2D绘图引擎:5.5.简单图表demo

MiniRender仓库地址参考

这正是检验“轮子”是否圆润的最佳时刻。我们将利用目前 MiniRender 已经具备的图形能力(Rect, Text)层级管理(Group, z-index)交互能力(Hover, Click),构建一个经典的柱状图(Bar Chart)

虽然我们还没有 ECharts 那样的高级配置项,但通过原生绘图指令,我们完全可以“手搓”一个出来。

功能点:

  1. 坐标轴:绘制 X 轴和 Y 轴(使用细长的 Rect 模拟线条)。
  2. 数据可视化:根据数据生成柱子。
  3. 交互反馈:鼠标悬停时,柱子高亮变色。
  4. 数据提示:悬停时,在柱子上方显示具体数值(简单的 Tooltip)。

代码实现 (index.ts)

请将以下代码放入你的入口文件。

import { init } from './core/MiniRender';
import { Group } from './graphic/Group';
import { Rect } from './graphic/shape/Rect';
import { Text } from './graphic/Text';

// 1. 初始化引擎
const dom = document.getElementById('main')!;
const miniRender = init(dom);

// --- 配置数据 ---
const data = [120, 200, 150, 80, 70, 110, 130];
const categories = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];

// --- 图表布局配置 ---
const chartConfig = {
    x: 50,          // 图表左边距
    y: 50,          // 图表上边距
    width: 500,     // 绘图区宽度
    height: 300,    // 绘图区高度
    barWidth: 30,   // 柱子宽度
    barColor: '#5470C6', // 默认颜色
    hoverColor: '#91CC75' // 高亮颜色
};

// 创建一个组来容纳整个图表,方便整体移动
const chartGroup = new Group({
    position: [chartConfig.x, chartConfig.y]
});

// --- 第一步:绘制坐标轴 (使用 Rect 模拟线) ---

// Y轴 (左侧竖线)
const yAxis = new Rect({
    shape: {
        x: 0, 
        y: 0, 
        width: 1, 
        height: chartConfig.height
    },
    style: { fill: '#333' }
});

// X轴 (底部横线)
const xAxis = new Rect({
    shape: {
        x: 0, 
        y: chartConfig.height, 
        width: chartConfig.width, 
        height: 1
    },
    style: { fill: '#333' }
});

chartGroup.add(yAxis);
chartGroup.add(xAxis);

// --- 第二步:准备 Tooltip (浮动提示文字) ---
// 我们创建一个共享的 Text 对象,默认隐藏,悬停时移动位置并显示
const tooltip = new Text({
    style: {
        text: '',
        fill: '#000',
        fontSize: 14,
        fontWeight: 'bold',
        textAlign: 'center',
        textBaseline: 'bottom'
    },
    z: 10,       // 保证在最上层
    invisible: true, // 初始隐藏
    silent: true // 关键:让 Tooltip 不阻挡鼠标,防止闪烁
});
// Tooltip 不加到 chartGroup,而是直接加到 miniRender,防止受 group 变换影响(虽然这里 chartGroup 没旋转)
// 但为了坐标方便,加到 chartGroup 里更容易计算相对坐标
chartGroup.add(tooltip);


// --- 第三步:绘制柱子与标签 ---

// 计算每个柱子的间隔
const step = chartConfig.width / data.length;
const maxVal = Math.max(...data);

data.forEach((value, index) => {
    // 1. 数据映射计算
    // 高度比例:value / maxVal
    const barHeight = (value / maxVal) * (chartConfig.height - 40); // 留40px顶部余量
    
    // 柱子左上角坐标 (相对于 chartGroup)
    // x = 间隔 * 索引 + 居中偏移
    const x = index * step + (step - chartConfig.barWidth) / 2;
    // y = 底部Y - 柱子高度
    const y = chartConfig.height - barHeight;

    // 2. 创建柱子
    const bar = new Rect({
        shape: {
            x: x,
            y: y,
            width: chartConfig.barWidth,
            height: barHeight
        },
        style: {
            fill: chartConfig.barColor
        }
    });

    // 3. 创建 X 轴分类文本
    const label = new Text({
        style: {
            text: categories[index],
            fill: '#666',
            fontSize: 12,
            textAlign: 'center',
            textBaseline: 'top'
        },
        position: [x + chartConfig.barWidth / 2, chartConfig.height + 10],
        silent: true // 文本不响应交互
    });

    // 4. 绑定交互事件
    bar.on('mouseover', () => {
        // 柱子变色
        bar.style.fill = chartConfig.hoverColor;
        
        // 显示 Tooltip
        tooltip.invisible = false;
        tooltip.style.text = `${value}`; // 设置数值
        // 移动 Tooltip 到柱子顶部中间
        tooltip.x = x + chartConfig.barWidth / 2;
        tooltip.y = y - 5; // 往上飘一点

        miniRender.refresh();
    });

    bar.on('mouseout', () => {
        // 颜色复原
        bar.style.fill = chartConfig.barColor;
        
        // 隐藏 Tooltip
        tooltip.invisible = true;

        miniRender.refresh();
    });

    chartGroup.add(bar);
    chartGroup.add(label);
});

// 将整个图表组添加到引擎
miniRender.add(chartGroup);

// 渲染第一帧
miniRender.refresh();

chart.gif

从零实现2D绘图引擎:5.鼠标悬停事件

MiniRender仓库地址参考

好的,我们开始 悬停事件 (Hover Events) 的实现。

这是交互体验中质的飞跃。目前的点击是“瞬间”的,而悬停是“连续”的状态管理。

核心逻辑分析

要实现 mouseover (移入) 和 mouseout (移出),Handler 需要记忆上一帧鼠标在哪一个图形上。

我们定义:

  • target: 当前鼠标下的图形。
  • _hovered: 上一次鼠标所在的图形(缓存状态)。

逻辑如下:

  1. 监听 DOM 的 mousemove
  2. 计算当前鼠标下的 target
  3. 对比 target_hovered
    • 如果 target !== _hovered,说明发生了状态切换:
      • 如果有 _hovered,对它触发 mouseout
      • 如果有 target,对它触发 mouseover
      • 更新 _hovered = target
  4. 为了更好的体验,当有 target 时,我们将鼠标指针设为手型 (pointer),否则设为默认 (default)。

1. 修改 Handler (src/handler/Handler.ts)

我们需要给 Handler 类添加状态属性,并增加 mousemove 的监听逻辑。

为了代码整洁,我提取了一个 _getEventPoint 方法来复用坐标计算。

// src/handler/Handler.ts
import { Storage } from '../storage/Storage';
import { Painter } from '../painter/Painter';
import { Displayable } from '../graphic/Displayable';

export class Handler {
    storage: Storage;
    painter: Painter;
    dom: HTMLElement;

    // 状态缓存:记录当前正悬停的元素
    private _hovered: Displayable | null = null;

    constructor(storage: Storage, painter: Painter, dom: HTMLElement) {
        this.storage = storage;
        this.painter = painter;
        this.dom = dom;
        this._initDomEvents();
    }

    private _initDomEvents() {
        // 绑定 this 上下文
        this.dom.addEventListener('click', this._clickHandler.bind(this));
        this.dom.addEventListener('mousemove', this._mouseMoveHandler.bind(this));
        // 这里还可以加 mousedown, mouseup 等
    }

    /**
     * 辅助方法:获取相对于 Canvas 左上角的坐标
     */
    private _getEventPoint(e: MouseEvent) {
        const rect = this.dom.getBoundingClientRect();
        return {
            x: e.clientX - rect.left,
            y: e.clientY - rect.top
        };
    }

    private _clickHandler(e: MouseEvent) {
        const { x, y } = this._getEventPoint(e);
        const target = this._findHover(x, y);

        if (target) {
            target.trigger('click', { target, event: e });
        }
    }

    /**
     * 核心:处理鼠标移动,计算 Hover 状态
     */
    private _mouseMoveHandler(e: MouseEvent) {
        const { x, y } = this._getEventPoint(e);
        const target = this._findHover(x, y);
        const lastHovered = this._hovered;

        // 如果鼠标下的元素变了
        if (target !== lastHovered) {
            
            // 1. 处理移出 (MouseOut)
            // 如果之前有悬停元素,说明从那个元素出来了
            if (lastHovered) {
                lastHovered.trigger('mouseout', { target: lastHovered, event: e });
            }

            // 2. 处理移入 (MouseOver)
            // 如果当前有元素,说明进入了这个元素
            if (target) {
                target.trigger('mouseover', { target: target, event: e });
            }

            // 3. 更新状态
            this._hovered = target;
        }

        // 4. 处理鼠标移动 (MouseMove)
        // 即使目标没变,也可以触发 move 事件
        if (target) {
            target.trigger('mousemove', { target, event: e });
        }

        // 5. 设置光标样式 (UX 优化)
        if (target) {
            this.dom.style.cursor = 'pointer';
        } else {
            this.dom.style.cursor = 'default';
        }
    }

    private _findHover(x: number, y: number): Displayable | null {
        const list = this.storage.getDisplayList();
        for (let i = list.length - 1; i >= 0; i--) {
            const el = list[i];
            if (el.invisible) continue;
            if (el.contain(x, y)) {
                return el;
            }
        }
        return null;
    }
}

2. 验证

现在我们来写一个复杂的 Demo。我们将创建多个矩形,每个矩形都有独立的悬停变色效果。

index.ts:

import { init } from './core/MiniRender';
import { Rect } from './graphic/shape/Rect';
import { Text } from './graphic/Text';

const miniRender = init(document.getElementById('main')!);

// 创建 5 个卡片
for (let i = 0; i < 5; i++) {
    const x = 50 + i * 110;
    const y = 100;

    // --- 创建矩形 ---
    const rect = new Rect({
        shape: { x: x, y: y, width: 100, height: 100, r: 5 },
        style: {
            fill: '#FFF',
            stroke: '#999',
            lineWidth: 2
        }
    });

    // --- 创建文本 ---
    const text = new Text({
        style: {
            text: `Card ${i + 1}`,
            fill: '#666',
            fontSize: 14,
            textAlign: 'center',
            textBaseline: 'middle'
        },
        position: [x + 50, y + 50],
        z: 1,
    });

    // --- 绑定交互事件 ---
    
    // 1. 移入高亮
    rect.on('mouseover', () => {
        console.log(`Mouse Over Rect ${i}`);
        
        // 变色
        rect.style.fill = '#E6F7FF'; // 浅蓝背景
        rect.style.stroke = '#1890FF'; // 深蓝边框
        
        // 放大动画效果(这里手动改 scale)
        // 稍微放大一点,注意 scale 是以 origin 为中心的
        // 我们还没有实现自动计算中心,所以这里手动设
        rect.origin = [x + 50, y + 50]; 
        rect.scale = [1.1, 1.1];

        miniRender.refresh();
    });

    // 2. 移出恢复
    rect.on('mouseout', () => {
        console.log(`Mouse Out Rect ${i}`);
        
        // 恢复颜色
        rect.style.fill = '#FFF';
        rect.style.stroke = '#999';
        
        // 恢复大小
        rect.scale = [1, 1];

        miniRender.refresh();
    });

    miniRender.add(rect);
    miniRender.add(text);
}

// 简单的提示
const tip = new Text({
    style: {
        text: 'Try Hovering on the cards!',
        fill: '#333',
        fontSize: 18,
    },
    position: [50, 30]
});
miniRender.add(tip);

// 渲染
miniRender.refresh();

预期效果

  1. 光标变化:当鼠标移动到卡片(白色矩形)上时,鼠标指针会变成手型。
  2. 高亮反馈
    • 移入时:卡片变大(1.1倍),背景变浅蓝,边框变深蓝。
    • 移出时:卡片恢复原状。
  3. 日志:控制台会打印出对应的 Mouse OverMouse Out
  4. 状态切换:如果你快速从卡片 1 移到卡片 2(不经过空白区),你会发现卡片 1 立刻恢复,卡片 2 立刻高亮。这就是 target !== lastHovered 逻辑在起作用。

3.缺陷

在上面的代码中,如果鼠标移到了中间的文字 Card N 上:

  1. 因为 Text 也是个 Displayable,而且在 Rect 上面 (z: 1)。
  2. Handler 会认为 target 变成了 Text
  3. 于是 Rect 会触发 mouseout(变回白色)。
  4. 如果你没给 Text 绑定事件,它就没有反应。

结果:鼠标在卡片边缘是高亮的,一移到文字上,卡片就“灭”了。

解决方案思路: 在 ZRender 和 ECharts 中,有一个属性叫 silent (静默)。 如果 el.silent = true,则 Handler_findHover 遍历时会直接跳过它 (continue)。这样检测到的 target 就会是底下的 Rect。

  1. 修改 src/graphic/Displayable.ts
export interface DisplayableProps extends ElementProps {
    // ...
    silent?: boolean; // 新增:是否响应交互
}

export abstract class Displayable extends Element {
    // ...
    silent: boolean = false;

    constructor(opts?: DisplayableProps) {
        super(opts);
        // ...
        if (opts && opts.silent != null) this.silent = opts.silent;
    }
}
  1. 修改 src/handler/Handler.ts_findHover
    private _findHover(x: number, y: number): Displayable | null {
        const list = this.storage.getDisplayList();
        for (let i = list.length - 1; i >= 0; i--) {
            const el = list[i];
            // 增加 !el.silent 判断
            if (el.invisible || el.silent) continue; 
            if (el.contain(x, y)) {
                return el;
            }
        }
        return null;
    }
  1. 修改 index.ts 中的 Text 创建:
    const text = new Text({
        // ... 样式
        silent: true // 关键!让文字不阻挡鼠标
    });

现在,鼠标移到文字上时,Handler 会忽略文字,直接检测到下面的 Rect,Hover 效果就不会中断了。

hover.gif

从零实现2D绘图引擎:4.矩形与文本的实现

MiniRender仓库地址参考

  • 矩形 (Rect):这是所有 UI 组件(按钮、卡片、背景)的基础。
  • 文本 (Text):这是信息展示的核心。

相较于圆,文本的难点在于“包围盒计算”(为了支持点击检测),因为 Canvas 只有画图命令,没有直接告诉我们字有多高。

我们将分两步实现。

1. 实现矩形 (src/graphic/shape/Rect.ts)

矩形的逻辑比较标准,重点是实现 buildPathcontainLocal

// src/graphic/shape/Rect.ts
import { Displayable, DisplayableProps } from '../Displayable';

export interface RectShape {
    x?: number;
    y?: number;
    width?: number;
    height?: number;
    r?: number; // 圆角半径 (简单起见,暂只支持统一圆角)
}

interface RectProps extends DisplayableProps {
    shape?: RectShape;
}

export class Rect extends Displayable {
    shape: Required<RectShape>; // 确保内部使用时都有值

    constructor(opts?: RectProps) {
        super(opts);
        this.shape = {
            x: 0, y: 0, width: 0, height: 0, r: 0,
            ...opts?.shape
        };
    }

    buildPath(ctx: CanvasRenderingContext2D) {
        const shape = this.shape;
        const x = shape.x;
        const y = shape.y;
        const width = shape.width;
        const height = shape.height;
        const r = shape.r;

        if (!r) {
            // 普通矩形
            ctx.rect(x, y, width, height);
        } else {
            // 圆角矩形 (使用 arcTo 或者 roundRect)
            // 这里使用通用的 arcTo 模拟
            ctx.moveTo(x + r, y);
            ctx.lineTo(x + width - r, y);
            ctx.arcTo(x + width, y, x + width, y + r, r);
            ctx.lineTo(x + width, y + height - r);
            ctx.arcTo(x + width, y + height, x + width - r, y + height, r);
            ctx.lineTo(x + r, y + height);
            ctx.arcTo(x, y + height, x, y + height - r, r);
            ctx.lineTo(x, y + r);
            ctx.arcTo(x, y, x + r, y, r);
            ctx.closePath();
        }
    }

    /**
     * 矩形的包含检测
     */
    containLocal(x: number, y: number): boolean {
        const shape = this.shape;
        // 简单矩形检测
        // 如果要做圆角检测比较复杂,通常这里简化为矩形包围盒
        return x >= shape.x && x <= shape.x + shape.width &&
               y >= shape.y && y <= shape.y + shape.height;
    }
}

2. 实现文本 (src/graphic/Text.ts)

文本比较特殊。

  1. 绘制方式不同:它不用 beginPath/fill 流程,而是直接 fillText
  2. 样式属性多:字号、字体、对齐方式。
  3. 碰撞检测难:需要用 measureText 算宽度,用字号估算高度。

我们需要先在 DisplayableStyle 中补充文本相关的样式定义。

更新 src/graphic/Style.ts:

export interface CommonStyle {
    // ... 原有属性 ...
    
    // 文本相关
    text?: string;
    fontSize?: number;
    fontFamily?: string;
    fontWeight?: string; // 'bold', 'normal'
    
    // 对齐
    textAlign?: CanvasTextAlign; // 'left' | 'right' | 'center' | 'start' | 'end'
    textBaseline?: CanvasTextBaseline; // 'top' | 'middle' | 'bottom' ...
}

创建 src/graphic/Text.ts:

注意:为了能在 brush 中使用特殊的绘制逻辑,我们这里覆盖 brush 方法,或者复用基类逻辑但重写 buildPath 实际上不太合适(因为 fillText 不是 path)。

ZRender 的做法是 Text 也是 Displayable,但绘制逻辑独立。为了 MiniRender 架构简单,我们重写 brush

// src/graphic/Text.ts
import { Displayable, DisplayableProps } from './Displayable';

// 默认字体
const DEFAULT_FONT_FAMILY = 'sans-serif';

export class Text extends Displayable {
    
    constructor(opts?: DisplayableProps) {
        super(opts);
    }

    /**
     * 重写 brush,因为文本不是 Path
     */
    brush(ctx: CanvasRenderingContext2D) {
        const style = this.style;
        if (!style.text) return;

        ctx.save();
        
        // 1. 设置常规样式
        if (style.fill) ctx.fillStyle = style.fill;
        if (style.stroke) ctx.strokeStyle = style.stroke;
        if (style.opacity != null) ctx.globalAlpha = style.opacity;

        // 2. 设置字体样式
        const fontSize = style.fontSize || 12;
        const fontFamily = style.fontFamily || DEFAULT_FONT_FAMILY;
        const fontWeight = style.fontWeight || '';
        ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`.trim();

        ctx.textAlign = style.textAlign || 'left';
        ctx.textBaseline = style.textBaseline || 'alphabetic';

        // 3. 应用变换
        const m = this.globalTransform;
        ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]);

        // 4. 绘制文本
        // 这里的 0, 0 是相对于 Text 元素自身的原点
        if (style.stroke) ctx.strokeText(style.text, 0, 0);
        if (style.fill) ctx.fillText(style.text, 0, 0);

        ctx.restore();
    }

    // 文本不需要 buildPath,因为我们在 brush 里直接画了
    buildPath(ctx: CanvasRenderingContext2D) {}

    /**
     * 文本的碰撞检测
     * 难点:计算文本的包围盒
     */
    containLocal(x: number, y: number): boolean {
        const style = this.style;
        if (!style.text) return false;

        // 借用一个辅助 canvas 来测量文本宽度(或者用全局单一实例)
        // 在真实项目中,应该缓存 measureText 的结果
        const ctx = document.createElement('canvas').getContext('2d')!;
        const fontSize = style.fontSize || 12;
        const fontFamily = style.fontFamily || DEFAULT_FONT_FAMILY;
        const fontWeight = style.fontWeight || '';
        ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`.trim();
        
        // 1. 计算宽
        const width = ctx.measureText(style.text).width;
        // 2. 估算高 (Canvas API 不直接提供高度,通常用 fontSize 估算)
        const height = fontSize;

        // 3. 根据对齐方式计算左上角 (Bounding Box 的 x, y)
        // 默认原点在 (0,0)
        let bx = 0;
        let by = 0;

        // 水平对齐修正
        const align = style.textAlign || 'left';
        if (align === 'center') {
            bx -= width / 2;
        } else if (align === 'right' || align === 'end') {
            bx -= width;
        }

        // 垂直对齐修正
        const baseline = style.textBaseline || 'alphabetic';
        if (baseline === 'top') {
            by = 0;
        } else if (baseline === 'middle') {
            by -= height / 2;
        } else if (baseline === 'bottom') {
            by -= height;
        } else {
            // alphabetic (基线) 大概在 bottom 偏上一点,这里简单按 bottom 处理或忽略
            by -= height; 
        }

        // 4. 判断点是否在矩形内
        return x >= bx && x <= bx + width &&
               y >= by && y <= by + height;
    }
}

3. 验证

现在我们可以在 index.ts 中同时使用圆形、矩形和文本,构建一个简单的 UI 按钮。

index.ts (测试代码)

import { init } from './core/MiniRender';
import { Group } from './graphic/Group';
import { Circle } from './graphic/shape/Circle';
import { Rect } from './graphic/shape/Rect'; // 新增
import { Text } from './graphic/Text';       // 新增

const miniRender = init(document.getElementById('main')!);

// --- 示例 1: 创建一个简单的按钮 (Group + Rect + Text) ---

const button = new Group({
    position: [100, 100], // 按钮整体位置
    // scale: [1.5, 1.5]     // 测试父级缩放对文本点击是否有效
});

// 1. 按钮背景
const bg = new Rect({
    shape: {
        x: 0, 
        y: 0, 
        width: 120, 
        height: 40, 
        r: 10 // 圆角
    },
    style: {
        fill: '#409EFF',
        stroke: '#000',
        lineWidth: 1
    }
});

// 2. 按钮文字
const label = new Text({
    style: {
        text: 'Hello World',
        fill: '#fff',
        fontSize: 16,
        textAlign: 'center',       // 水平居中
        textBaseline: 'middle'     // 垂直居中
    },
    // 将文字放到按钮中心
    position: [60, 20], // 120/2, 40/2
    z: 1 // 确保文字在背景上面
});

button.add(bg);
button.add(label);
miniRender.add(button);

// --- 交互测试 ---

// 点击背景变色
bg.on('click', () => {
    console.log('Background clicked');
    bg.style.fill = bg.style.fill === '#409EFF' ? '#67C23A' : '#409EFF';
    miniRender.refresh();
});

// 点击文字变色
label.on('click', () => {
    console.log('Text clicked');
    label.style.fill = label.style.fill === '#fff' ? '#000' : '#fff';
    miniRender.refresh();
});

// --- 动画测试 ---
// 让按钮慢慢旋转,测试 Rect 和 Text 的点击区域是否跟着旋转
let angle = 0;
function loop() {
    angle += 0.01;
    button.rotation = angle;
    
    // 如果想要看旋转效果,取消下面注释
    miniRender.refresh(); 
    requestAnimationFrame(loop);
}
loop();

text.gif

从零实现2D绘图引擎:3.交互系统(Handle)的实现

MiniRender仓库地址参考

要实现交互,我们需要解决三个层面的问题:

  1. 数学层:矩阵求逆(为了把鼠标点转换到图形内部坐标系)。
  2. 基类层:事件订阅机制(On/Off)和包含判断接口(Contain)。
  3. 控制层:监听 DOM 事件并分发给图形。

1.数学工具升级 (矩阵求逆)

我们在 src/utils/matrix.ts 中只实现了乘法和合成。为了做碰撞检测,我们需要逆矩阵。 图形被平移、旋转、缩放了。判断点 (100, 100) 是否在被旋转了 30 度的矩形里很难;但把点逆向旋转 30 度,判断它是否在未旋转的矩形里很简单。

src/utils/matrix.ts 中追加:

// src/utils/matrix.ts

/**
 * 求逆矩阵
 * out = invert(a)
 */
export function invert(out: MatrixArray, a: MatrixArray): MatrixArray {
    const aa = a[0], ac = a[2], atx = a[4];
    const ab = a[1], ad = a[3], aty = a[5];

    // 计算行列式
    let det = aa * ad - ab * ac;
    if (!det) {
        // 行列式为0,无法求逆,返回 null 或 设为单位矩阵
        return [1, 0, 0, 1, 0, 0] as any; // 简单处理
    }
    det = 1.0 / det;

    out[0] = ad * det;
    out[1] = -ab * det;
    out[2] = -ac * det;
    out[3] = aa * det;
    out[4] = (ac * aty - ad * atx) * det;
    out[5] = (ab * atx - aa * aty) * det;
    return out;
}

2.实现事件中心 (Eventful)

我们需要一个类来管理 .on, .trigger

创建 src/core/Eventful.ts

// src/core/Eventful.ts

type EventHandler = (...args: any[]) => void;

export class Eventful {
    private _handlers: { [event: string]: EventHandler[] } = {};

    on(event: string, handler: EventHandler): this {
        if (!this._handlers[event]) {
            this._handlers[event] = [];
        }
        this._handlers[event].push(handler);
        return this;
    }

    off(event?: string, handler?: EventHandler): this {
        // 简化实现:清空指定事件或全部
        if (event && !handler) {
            this._handlers[event] = [];
        } else if (!event) {
            this._handlers = {};
        }
        // 完整实现还需要处理移除特定 handler,这里略过
        return this;
    }

    trigger(event: string, ...args: any[]): this {
        const handlers = this._handlers[event];
        if (handlers) {
            handlers.forEach(h => h.apply(this, args));
        }
        return this;
    }
}

Element 继承 Eventful。 修改 src/graphic/Element.ts

import { Eventful } from '../core/Eventful';
// ...
export abstract class Element extends Eventful { 
    // ... 
}

3.图形拾取逻辑 (Element & Shape)

我们需要在 Displayable 中定义标准,并在 Circle 中实现具体算法。

1. 修改 src/graphic/Element.ts 增加坐标转换方法。这是交互的核心。

// src/graphic/Element.ts

export abstract class Element extends Eventful {
    // ... 原有代码 ...

    // 辅助矩阵,避免重复创建对象 (GC优化)
    private static _invertMat: MatrixArray = matrix.create();

    /**
     * 将全局坐标转换到当前元素的局部坐标系
     * @param x 全局 x
     * @param y 全局 y
     * @return [localX, localY]
     */
    globalToLocal(x: number, y: number): Point {
        const m = this.globalTransform;
        // 计算逆矩阵
        // 注意:这里用简单的静态变量缓存逆矩阵,非线程安全但JS是单线程所以OK
        const inv = Element._invertMat;
        matrix.invert(inv, m);

        // 应用逆变换: x' = a*x + c*y + tx
        const lx = inv[0] * x + inv[2] * y + inv[4];
        const ly = inv[1] * x + inv[3] * y + inv[5];
        
        return [lx, ly];
    }
}

2. 修改 src/graphic/Displayable.ts 增加抽象方法 contain

// src/graphic/Displayable.ts

export abstract class Displayable extends Element {
    // ... 原有代码 ...

    /**
     * 判断点是否在图形内
     * @param x 全局 x
     * @param y 全局 y
     */
    contain(x: number, y: number): boolean {
        // 1. 转换为局部坐标
        const local = this.globalToLocal(x, y);
        // 2. 调用具体形状的几何判断
        return this.containLocal(local[0], local[1]);
    }

    /**
     * 具体形状实现这个方法,判断局部坐标是否在路径内
     */
    abstract containLocal(x: number, y: number): boolean;
}

3. 修改 src/graphic/shape/Circle.ts 实现圆形的几何判断。

// src/graphic/shape/Circle.ts

export class Circle extends Displayable {
    // ... 原有代码 ...

    containLocal(x: number, y: number): boolean {
        // 圆形判断很简单:点到圆心的距离 < 半径
        // 注意:这里的 x,y 已经是经过逆变换的,所以是在圆没有被旋转缩放的坐标系下
        // 而 this.shape.cx/cy 也是在这个坐标系下
        const d2 = Math.pow(x - this.shape.cx, 2) + Math.pow(y - this.shape.cy, 2);
        return d2 <= this.shape.r * this.shape.r;
    }
}

4.实现 Handler 控制器

这是最后一块拼图。它监听 DOM 事件,找到图形,然后由图形触发事件。

创建 src/handler/Handler.ts

// src/handler/Handler.ts
import { Storage } from '../storage/Storage';
import { Painter } from '../painter/Painter';
import { Displayable } from '../graphic/Displayable';

export class Handler {
    storage: Storage;
    painter: Painter;
    dom: HTMLElement;

    constructor(storage: Storage, painter: Painter, dom: HTMLElement) {
        this.storage = storage;
        this.painter = painter;
        this.dom = dom;

        // 初始化 DOM 监听
        this._initDomEvents();
    }

    private _initDomEvents() {
        // 简单的监听 click 事件作为示例
        this.dom.addEventListener('click', (e) => {
            this._clickHandler(e);
        });
        
        // 实际还有 mousedown, mouseup, mousemove 等复杂逻辑
    }

    private _clickHandler(e: MouseEvent) {
        // 1. 获取相对于 Canvas 的坐标
        // getBoundingClientRect 包含了页面滚动和边框
        const rect = this.dom.getBoundingClientRect();
        // Canvas 的实际像素尺寸是 CSS 尺寸的 dpr 倍
        const x = (e.clientX - rect.left - this.dom.clientLeft) * window.devicePixelRatio;
        const y = (e.clientY - rect.top - this.dom.clientTop) * window.devicePixelRatio;

        // 2. 寻找被点击的图形
        const target = this._findHover(x, y);

        if (target) {
            // 3. 触发图形事件
            console.log('Clicked shape:', target.id);
            target.trigger('click', { target: target, event: e });
        } else {
            console.log('Clicked empty space');
        }
    }

    private _findHover(x: number, y: number): Displayable | null {
        const list = this.storage.getDisplayList();
        
        // 核心:逆序遍历!
        // 因为 displayList 是按渲染顺序排的(后面的盖在前面),
        // 所以我们检测点击时,要从最上面(数组末尾)开始查。
        for (let i = list.length - 1; i >= 0; i--) {
            const el = list[i];
            
            // 忽略不可见或不响应鼠标的元素
            if (el.invisible) continue; // 可以再加 ignoreMouse 等标志

            // 碰撞检测
            if (el.contain(x, y)) {
                return el;
            }
        }
        return null;
    }
}

5.集成到 MiniRender

修改 src/core/MiniRender.ts,初始化 Handler。

// src/core/MiniRender.ts
import { Handler } from '../handler/Handler';

export class MiniRender {
    storage: Storage;
    painter: Painter;
    handler: Handler; // 新增

    constructor(dom: HTMLElement) {
        this.storage = new Storage();
        this.painter = new Painter(dom, this.storage);
        // 初始化交互系统
        this.handler = new Handler(this.storage, this.painter, dom);
    }
    // ...
}

6.测试

现在我们修改 index.ts 来测试点击事件。我们利用 Eventful 的能力。

// index.ts
import { init } from './core/MiniRender';
import { Group } from './graphic/Group';
import { Circle } from './graphic/shape/Circle';

const miniRender = init(document.getElementById('main')!);

const group = new Group({ position: [200, 200] });

// 一个红色的圆
const circle = new Circle({
    shape: { r: 50 },
    style: { fill: '#F00' }
});

// 绑定点击事件!
circle.on('click', () => {
    console.log('Circle Clicked!');
    // 点击变色
    if (circle.style.fill === '#F00') {
        circle.style.fill = '#00F'; // 变蓝
    } else {
        circle.style.fill = '#F00'; // 变红
    }
    miniRender.refresh(); // 记得手动刷新
});

group.add(circle);
miniRender.add(group);

// 动画:让它旋转,测试旋转后的点击检测是否准确
let angle = 0;
function loop() {
    angle += 0.01;
    group.rotation = angle;
    // 手动更新 Group 属性,Painter 会在 refresh 时计算矩阵
    
    // 如果想要点击生效,不需要一直 refresh,但为了看动画:
    miniRender.refresh();
    requestAnimationFrame(loop);
}
loop();
  1. 屏幕上有一个旋转的圆。
  2. 当你点击圆的内部时,控制台输出 "Circle Clicked!",并且圆颜色在红蓝之间切换。
  3. 关键点:即使圆旋转到了奇怪的角度,或者被 Group 缩放了,只要你的鼠标点在视觉上的圆内,事件就应该触发。这就是 invert 逆矩阵的作用。

handler.gif

从零实现2D绘图引擎:2.Storage和Painter的实现

MiniRender仓库地址参考

好的,我们开始 仓库 (Storage) 与 渲染器 (Painter) 的实现。

这一步的目标是把“手动挡”变成“自动挡”。我们将不再手动调用 circle.brush(ctx),而是构建一个自动化系统:Storage 负责管理所有图形,Painter 负责把 Storage 里的东西画出来。

为了让 Storage 的逻辑完整,我们需要先补充一个简单的 Group 类(容器),因为 Storage 本质上是在遍历一棵树。

1. 容器类实现 (src/graphic/Group.ts)

Group 继承自 Element。它不画任何东西(没有 brush 方法),它的作用是把子元素“打包”,并且传递变换矩阵。

// src/graphic/Group.ts
import { Element } from './Element';

export class Group extends Element {
    readonly isGroup = true;
    
    // 子节点列表
    children: Element[] = [];

    /**
     * 添加子节点
     */
    add(child: Element) {
        if (child && child !== this && child.parent !== this) {
            this.children.push(child);
            child.parent = this; // 建立父子链接
        }
    }

    /**
     * 移除子节点
     */
    remove(child: Element) {
        const idx = this.children.indexOf(child);
        if (idx >= 0) {
            this.children.splice(idx, 1);
            child.parent = null;
        }
    }
}

2. 仓库模块 (src/storage/Storage.ts)

Storage 是内存中的数据库。它的核心职责是:将场景图(树状结构)扁平化为一个渲染列表(数组),并按层级排序。

// src/storage/Storage.ts
import { Element } from '../graphic/Element';
import { Displayable } from '../graphic/Displayable';
import { Group } from '../graphic/Group';

// 类型守卫:判断是否为 Group
function isGroup(el: Element): el is Group {
    return (el as Group).isGroup;
}

// 类型守卫:判断是否为 Displayable
function isDisplayable(el: Element): el is Displayable {
    return el instanceof Displayable;
}

export class Storage {
    // 根节点列表 (Scene Graph 的入口)
    private _roots: Element[] = [];
    
    // 扁平化的渲染列表 (缓存结果)
    private _displayList: Displayable[] = [];

    // 标记列表是否脏了(需要重新遍历和排序)
    private _displayListDirty: boolean = true;

    addRoot(el: Element) {
        this._roots.push(el);
        this._displayListDirty = true;
    }

    /**
     * 核心方法:获取排序后的渲染列表
     * 逻辑:
     * 1. 深度优先遍历所有根节点
     * 2. 收集所有的 Displayable
     * 3. 按 zLevel 和 z 排序
     */
    getDisplayList(): Displayable[] {
        if (this._displayListDirty) {
            this._updateDisplayList();
            this._displayListDirty = false;
        }
        return this._displayList;
    }

    private _updateDisplayList() {
        const list: Displayable[] = [];
        
        // 1. 递归遍历 (DFS)
        const traverse = (el: Element) => {
            if (isDisplayable(el)) {
                list.push(el);
            }
            if (isGroup(el)) {
                for (let i = 0; i < el.children.length; i++) {
                    traverse(el.children[i]);
                }
            }
        };

        for (let i = 0; i < this._roots.length; i++) {
            traverse(this._roots[i]);
        }

        // 2. 排序
        // 优先级:zLevel (Canvas层) > z (同层叠加顺序) > 插入顺序
        list.sort((a, b) => {
            if (a.zLevel === b.zLevel) {
                return a.z - b.z;
            }
            return a.zLevel - b.zLevel;
        });

        this._displayList = list;
    }
}

3. 渲染器模块 (src/painter/Painter.ts)

Painter 是也是最“脏”的地方,因为它要直接操作 DOM。为了保持简单,我们暂时只实现单层 Canvas(假设所有图形 zLevel 都是 0)。

核心逻辑

  1. 初始化时创建 <canvas> 并插入 DOM。
  2. refresh 方法负责清空画布、获取列表、更新矩阵、绘制。
// src/painter/Painter.ts
import { Storage } from '../storage/Storage';

export class Painter {
    private _dom: HTMLElement;
    private _storage: Storage;
    
    private _canvas: HTMLCanvasElement;
    private _ctx: CanvasRenderingContext2D;
    
    private _width: number = 0;
    private _height: number = 0;

    constructor(dom: HTMLElement, storage: Storage) {
        this._dom = dom;
        this._storage = storage;

        // 1. 创建 Canvas
        this._canvas = document.createElement('canvas');
        // 简单的样式设置
        this._canvas.style.cssText = 'position:absolute;left:0;top:0;width:100%;height:100%';
        dom.appendChild(this._canvas);
        
        this._ctx = this._canvas.getContext('2d')!;

        // 初始化大小
        this.resize();
        
        // 监听窗口大小变化(简单版)
        window.addEventListener('resize', () => this.resize());
    }

    resize() {
        // 获取容器宽高
        const width = this._dom.clientWidth;
        const height = this._dom.clientHeight;
        
        // 处理高清屏 (Retina)
        const dpr = window.devicePixelRatio || 1;
        
        this._canvas.width = width * dpr;
        this._canvas.height = height * dpr;
        
        // 缩放 Context,这样绘图时直接用逻辑坐标,不用管 dpr
        this._ctx.scale(dpr, dpr);

        this._width = width;
        this._height = height;
        
        // 大小变了,必须重绘
        this.refresh();
    }

    /**
     * 渲染入口
     */
    refresh() {
        const list = this._storage.getDisplayList();
        const ctx = this._ctx;

        // 1. 清空画布
        ctx.clearRect(0, 0, this._width, this._height);

        // 2. 遍历绘制
        for (let i = 0; i < list.length; i++) {
            const el = list[i];
            
            // 优化:看不见的直接跳过
            // if (el.invisible) continue;

            // 3. 关键步骤:更新变换矩阵
            // 注意:必须从根节点开始 update,这里为了简化,
            // 假设 Storage 里的顺序已经保证了父级在子级之前,或者 el.updateTransform 内部会自动回溯父级。
            // 在真正的 MiniRender 中,会在 refresh 前统一更新一遍所有节点的 globalTransform。
            el.updateTransform(); 

            // 4. 绘制
            el.brush(ctx);
        }
    }
}

4. 入口类 (src/core/MiniRender.ts)

这是给开发者用的“门面”(Facade)。它把 StoragePainter 组装起来。

// src/core/MiniRender.ts
import { Storage } from '../storage/Storage';
import { Painter } from '../painter/Painter';
import { Element } from '../graphic/Element';

export class MiniRender {
    storage: Storage;
    painter: Painter;

    constructor(dom: HTMLElement) {
        this.storage = new Storage();
        this.painter = new Painter(dom, this.storage);
    }

    /**
     * 添加图形元素
     */
    add(el: Element) {
        this.storage.addRoot(el);
        this.refresh(); // 暂时:每次添加都立即刷新
    }

    /**
     * 触发重绘
     */
    refresh() {
        // 在真实 MiniRender 中,这里会使用 requestAnimationFrame 进行防抖
        this.painter.refresh();
    }
}

/**
 * 工厂函数
 */
export function init(dom: HTMLElement) {
    return new MiniRender(dom);
}

5. 测试

现在我们拥有了一个完整的静态渲染引擎。我们可以创建一个带有层级关系的场景。

index.ts (测试代码):

import { init } from './core/MiniRender';
import { Group } from './graphic/Group';
import { Circle } from './graphic/shape/Circle';

// 1. 初始化
const container = document.getElementById('main')!;
const miniRender = init(container);

// 2. 创建一个 Group (当作太阳系中心)
const sunGroup = new Group();
sunGroup.x = 300;
sunGroup.y = 300;

// 3. 创建一个红色的太阳 (加入 Group)
const sun = new Circle({
    shape: { r: 50 },
    style: { fill: '#F00' }
});
sunGroup.add(sun);

// 4. 创建一个蓝色的地球 (加入 Group,相对太阳偏移)
const earth = new Circle({
    shape: { r: 20 },
    style: { fill: '#00F' },
    position: [100, 0] // 距离太阳中心 100px
});
sunGroup.add(earth);

// 5. 创建一个月亮 (加入地球 Group? 这里为了简单,我们让月亮单独在地球旁边)
// 演示层级:我们把月亮直接加到 Group 里,但是 Z 设低一点
const moon = new Circle({
    shape: { r: 10 },
    style: { fill: '#CCC' },
    position: [120, 0], // 在地球右边
    z: -1 // 放在最下面 (测试排序)
});
sunGroup.add(moon);

// 6. 添加到 miniRender
miniRender.add(sunGroup);

// --- 动起来!(简单的动画循环) ---
// 这验证了 Painter 的 refresh 和 Storage 的 updateTransform
let angle = 0;
function loop() {
    angle += 0.02;
    
    // 旋转整个太阳系
    sunGroup.rotation = angle;
    
    // 自转地球 (修改属性,标记 dirty)
    // 注意:MiniRender 还没实现 dirty 标记,我们需要手动调 refresh
    
    miniRender.refresh();
    requestAnimationFrame(loop);
}

loop();

此时会发现一个问题,各图形位置不正确

6.图形位置问题

当前存在两个问题:

  • group内各图形相对位置错误
  • 整个group位置错误

A.解决group内的相对位置错误问题

回顾我们使用的代码:

错误原因:我们在 Circle 的构造函数中只处理了 shape 和 style,完全忽略了继承自 Element 的通用属性(position, scale, rotation)

我们需要在基类中处理这些通用属性的初始化。

1. 修改 src/graphic/Element.ts

增加一个初始化方法,用于解析 opts。

// src/graphic/Element.ts
import * as matrix from '../utils/matrix';
import { MatrixArray, Point } from '../utils/types';

export interface ElementProps {
    position?: Point; // [x, y]
    rotation?: number;
    scale?: Point;    // [sx, sy]
    origin?: Point;   // [ox, oy]
}

export abstract class Element {
    // ... 之前的属性定义 ...

    constructor(opts?: ElementProps) {
        this.id = `el_${idBase++}`;
        if (opts) {
            this.attr(opts);
        }
    }

    /**
     * 仿照 MiniRender 的 attr 方法,用于更新属性
     */
    attr(opts: ElementProps) {
        if (opts.position) {
            this.x = opts.position[0];
            this.y = opts.position[1];
        }
        if (opts.rotation != null) {
            this.rotation = opts.rotation;
        }
        if (opts.scale) {
            this.scaleX = opts.scale[0];
            this.scaleY = opts.scale[1];
        }
        if (opts.origin) {
            this.originX = opts.origin[0];
            this.originY = opts.origin[1];
        }
    }

    // ... updateTransform 等方法保持不变 ...
}
2. 修改 src/graphic/Displayable.ts

让子类将 opts 传递给 super。

// src/graphic/Displayable.ts
import { Element, ElementProps } from './Element';

// 组合类型
export interface DisplayableProps extends ElementProps {
    style?: any;
    z?: number;
    zLevel?: number;
    invisible?: boolean;
}

export abstract class Displayable extends Element {
    // ... 属性定义 ...

    constructor(opts?: DisplayableProps) {
        super(opts); // 关键!把 opts 传给 Element 处理 position/rotation
        
        if (opts) {
            if (opts.style) this.style = opts.style;
            if (opts.z != null) this.z = opts.z;
            if (opts.zLevel != null) this.zLevel = opts.zLevel;
            if (opts.invisible != null) this.invisible = opts.invisible;
        }
    }
    // ... brush 等方法 ...
}
3.修改 src/graphic/shape/Circle.ts
// src/graphic/shape/Circle.ts
import { Displayable, DisplayableProps } from '../Displayable';

interface CircleProps extends DisplayableProps {
    shape?: { cx?: number, cy?: number, r?: number };
}

export class Circle extends Displayable {
    shape: { cx: number, cy: number, r: number };

    constructor(opts?: CircleProps) {
        // 传递 opts 给父类
        super(opts); 
        
        // 处理自己特有的 shape
        this.shape = { cx: 0, cy: 0, r: 0, ...opts?.shape };
    }
    
    // ... buildPath ...
}

B.解决group的位置问题

在之前的 Painter.ts 代码中,我们在 refresh 循环渲染列表时调用 updateTransform。但存在一个问题,我们获取到的是displayable类型的图形,没有计算group这种根元素的矩阵。

解决方案: 将“计算矩阵”和“渲染绘制”分成了两个独立的遍历过程。

  1. Update 阶段:从根节点开始,递归(DFS)遍历整棵树,计算所有节点的 globalTransform。保证父级一定比子级先计算。
  2. Render 阶段:获取扁平化的 displayList(已排序),直接使用计算好的矩阵进行绘制。

我们需要修改 Painter.ts 或 Storage.ts 来体现这个逻辑。最简单的方法是在 Painter.refresh 中先更树,再画表。

修改 src/painter/Painter.ts

// src/painter/Painter.ts

export class Painter {
    // ... 

    refresh() {
        const list = this._storage.getDisplayList();
        const roots = this._storage.getRoots(); // 假设 Storage 暴露了 _roots
        const ctx = this._ctx;

        // 步骤 0: 确保 Canvas 尺寸正确 (防止 resize 没触发导致 width=0)
        if (this._width === 0) this.resize();

        // 步骤 1: 优先更新全场景图的变换矩阵 (MiniRender 核心逻辑)
        // 必须从根节点开始递归,确保父级矩阵先于子级生成
        roots.forEach(el => this._updateElementTransform(el));

        // 步骤 2: 清空画布
        ctx.clearRect(0, 0, this._width, this._height);

        // 步骤 3: 绘制扁平列表
        for (let i = 0; i < list.length; i++) {
            const el = list[i];
            // 此时 el.globalTransform 已经是正确的了,直接画
            el.brush(ctx);
        }
    }

    // 递归更新帮助函数
    private _updateElementTransform(el: Element) {
        el.updateTransform();
        // 如果是 Group,递归更新子节点
        if ((el as any).isGroup) {
            const children = (el as any).children;
            for (let i = 0; i < children.length; i++) {
                this._updateElementTransform(children[i]);
            }
        }
    }
}

注意:你需要在 Storage.ts 中增加一个 getRoots() 方法来返回 _roots 数组。

// src/storage/Storage.ts
public getRoots(): Element[] {
    return this._roots;
}

动画.gif

此时各图形位置将正确展示在画布中。

!

从零实现2D绘图引擎:1.实现数学工具库与基础图形类

MiniRender仓库地址参考

好的,我们开始 数学工具与基础图形类 的实现。

这是整个引擎的基石。如果这里的矩阵运算或者父子坐标变换写错了,后续所有的交互判定和渲染位置都会错乱。

1. 基础类型定义 (src/utils/types.ts)

为了代码清晰,我们先统一定义一些类型。

// src/utils/types.ts

// 向量/点: [x, y]
export type Point = [number, number];

// 3x2 仿射变换矩阵
// index: [0, 1, 2, 3, 4, 5] -> [a, b, c, d, tx, ty]
// 数学表示:
// | a c tx |
// | b d ty |
// | 0 0 1  |
export type MatrixArray = Float32Array | number[];

export interface BoundingRect {
    x: number;
    y: number;
    width: number;
    height: number;
}

2. 矩阵运算库 (src/utils/matrix.ts)

这是处理复杂层级和动画的核心。我们只需要实现最关键的几个方法:创建、乘法(级联)、合成(属性转矩阵)。

// src/utils/matrix.ts
import { MatrixArray } from './types';

// 创建单位矩阵
export function create(): MatrixArray {
    return [1, 0, 0, 1, 0, 0];
}

// 矩阵乘法: out = m1 * m2
// 用于计算 父级矩阵 * 子级局部矩阵 = 子级全局矩阵
export function mul(out: MatrixArray, m1: MatrixArray, m2: MatrixArray): MatrixArray {
    const out0 = m1[0] * m2[0] + m1[2] * m2[1];
    const out1 = m1[1] * m2[0] + m1[3] * m2[1];
    const out2 = m1[0] * m2[2] + m1[2] * m2[3];
    const out3 = m1[1] * m2[2] + m1[3] * m2[3];
    const out4 = m1[0] * m2[4] + m1[2] * m2[5] + m1[4];
    const out5 = m1[1] * m2[4] + m1[3] * m2[5] + m1[5];
    
    out[0] = out0; out[1] = out1;
    out[2] = out2; out[3] = out3;
    out[4] = out4; out[5] = out5;
    return out;
}

// 核心:将平移、缩放、旋转属性合成为一个矩阵
// 变换顺序:Translate -> Rotate -> Scale (标准顺序)
export function compose(
    out: MatrixArray,
    x: number, y: number,
    scaleX: number, scaleY: number,
    rotation: number
): MatrixArray {
    const sr = Math.sin(rotation);
    const cr = Math.cos(rotation);

    // 矩阵公式推导结果
    out[0] = cr * scaleX;
    out[1] = sr * scaleX;
    out[2] = -sr * scaleY;
    out[3] = cr * scaleY;
    out[4] = x;
    out[5] = y;
    
    return out;
}

// 克隆矩阵
export function clone(m: MatrixArray): MatrixArray {
    return Array.from(m); // 简易版
}

3. 元素基类 (src/graphic/Element.ts)

Element 不负责画画,只负责“我在哪”和“我的父级是谁”。它是场景图(Scene Graph)的节点。

核心逻辑updateTransform 方法。它负责先算自己的局部矩阵,然后看有没有爸爸。如果有,乘上爸爸的矩阵。

// src/graphic/Element.ts
import * as matrix from '../utils/matrix';
import { MatrixArray } from '../utils/types';

// 用一个简单的 GUID 生成器
let idBase = 0;

export abstract class Element {
    id: string;
    
    // --- 变换属性 (Transform Props) ---
    x: number = 0;
    y: number = 0;
    scaleX: number = 1;
    scaleY: number = 1;
    rotation: number = 0; // 弧度制

    // --- 矩阵状态 (Matrix State) ---
    // 局部变换矩阵 (相对于父级)
    localTransform: MatrixArray = matrix.create();
    // 全局变换矩阵 (相对于 Canvas 左上角)
    globalTransform: MatrixArray = matrix.create();

    // --- 层级关系 ---
    parent: Element | null = null;

    constructor() {
        this.id = `el_${idBase++}`;
    }

    /**
     * 核心方法:更新变换矩阵
     * 递归更新:通常由渲染器从根节点开始调用
     */
    updateTransform() {
        // 1. 根据属性计算局部矩阵
        // 优化:如果没有任何变换,保持单位矩阵 (此处省略优化,直接计算)
        matrix.compose(
            this.localTransform,
            this.x, this.y,
            this.scaleX, this.scaleY,
            this.rotation
        );

        // 2. 计算全局矩阵
        const parentTransform = this.parent && this.parent.globalTransform;
        
        if (parentTransform) {
            // 有父级:全局 = 父级全局 * 自身局部
            matrix.mul(this.globalTransform, parentTransform, this.localTransform);
        } else {
            // 无父级:全局 = 自身局部
            // 注意:这里需要拷贝,防止引用错乱
            for(let i = 0; i < 6; i++) {
                this.globalTransform[i] = this.localTransform[i];
            }
        }
    }
}

4. 样式接口 (src/graphic/Style.ts)

定义简单的 Canvas 样式。

// src/graphic/Style.ts
export interface CommonStyle {
    fill?: string;       // 填充颜色
    stroke?: string;     // 描边颜色
    lineWidth?: number;  // 线宽
    opacity?: number;    // 透明度 0-1
    shadowBlur?: number;
    shadowColor?: string;
    // ... 其他 Canvas 样式
}

5. 可绘制对象基类 (src/graphic/Displayable.ts)

Displayable 继承自 Element,负责将对象真正“画”到 Context 上。

核心逻辑brush 方法。它是渲染管线的核心步骤。

// src/graphic/Displayable.ts
import { Element } from './Element';
import { CommonStyle } from './Style';

export abstract class Displayable extends Element {
    
    style: CommonStyle = {};
    
    // 绘制顺序,类似于 CSS z-index
    z: number = 0;
    
    // 层级,不同的 zLevel 会被绘制在不同的 Canvas 实例上 (Layer)
    zLevel: number = 0;

    /**
     * 绘制入口
     * @param ctx 原生 CanvasContext
     */
    brush(ctx: CanvasRenderingContext2D) {
        const style = this.style;
        
        // 1. 保存当前 Context 状态
        ctx.save();

        // 2. 应用样式
        if (style.fill) ctx.fillStyle = style.fill;
        if (style.stroke) ctx.strokeStyle = style.stroke;
        if (style.lineWidth) ctx.lineWidth = style.lineWidth;
        // ... 其他样式应用

        // 3. 应用变换 (关键!)
        // setTransform(a, b, c, d, e, f)
        // 使用 globalTransform,这样 Canvas 原点就变到了图形的坐标系下
        const m = this.globalTransform;
        ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]);

        // 4. 开始路径
        ctx.beginPath();
        
        // 5. 调用具体形状的路径构建逻辑
        this.buildPath(ctx);

        // 6. 绘制
        ctx.closePath(); // 可选
        if (style.fill) ctx.fill();
        if (style.stroke) ctx.stroke();

        // 7. 恢复 Context 状态 (弹出 save 的状态)
        ctx.restore();
    }

    /**
     * 抽象方法:由子类实现具体的路径
     * 例如 Circle 会调用 ctx.arc
     */
    abstract buildPath(ctx: CanvasRenderingContext2D): void;
}

6. 具体图形实现:圆形 (src/graphic/shape/Circle.ts)

最后,我们实现一个具体的图形来验证这一套逻辑。

// src/graphic/shape/Circle.ts
import { Displayable } from '../Displayable';

interface CircleShape {
    cx?: number;
    cy?: number;
    r?: number;
}

export class Circle extends Displayable {
    
    // 图形特有的几何属性
    shape: CircleShape;

    constructor(opts?: { shape?: CircleShape, style?: any, z?: number }) {
        super();
        this.shape = { cx: 0, cy: 0, r: 0, ...opts?.shape };
        if (opts?.style) this.style = opts.style;
        if (opts?.z) this.z = opts.z;
    }

    buildPath(ctx: CanvasRenderingContext2D) {
        const shape = this.shape;
        // 直接调用 Canvas API
        // 注意:因为我们在 Displayable.brush 中已经做了 setTransform
        // 这里的 cx, cy 是相对于图形自身坐标系的位置
        ctx.arc(shape.cx!, shape.cy!, shape.r!, 0, Math.PI * 2);
    }
}

7.验证

虽然我们还没有 StoragePainter,但我们可以写一段模拟代码来验证 变换矩阵 是否生效。

// test.ts (模拟运行)
import { Circle } from './graphic/shape/Circle';

// 1. 模拟一个 Canvas
const canvas = document.createElement('canvas');
canvas.width = 500;
canvas.height = 500;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d')!;

// 2. 创建一个圆
const circle = new Circle({
    shape: { cx: 0, cy: 0, r: 50 }, // 圆心在 0,0
    style: { fill: 'red' }
});

// 3. 设置变换属性
circle.x = 200;      // 移到 x=200
circle.y = 200;      // 移到 y=200
circle.scaleX = 2;   // 宽度放大 2 倍
circle.rotation = Math.PI / 4; // 旋转 45 度

// 4. 手动更新矩阵 (正常这是由 MiniRender 系统做的)
circle.updateTransform();

console.log('Global Matrix:', circle.globalTransform);
// 预期:tx=200, ty=200, 且 a,b,c,d 有值(因为有缩放和旋转)

// 5. 手动绘制 (正常这是由 Painter 做的)
// 清空画布
ctx.clearRect(0, 0, 500, 500);
// 绘制
circle.brush(ctx);

image-20251203005357167.png

❌