用canvas实现一个供需关系图

759 阅读13分钟

成果展示

(先把代码部分隐藏后再尝试demo,拖动底部滚动条会使demo出现一些问题)

前言

  在经济学中,供需关系是一个简单但十分强大的分析工具,不过它常常以图片的形式来展示,我想,能不能借助前端领域强大的交互能力,来赋予它更好的表达效果呢。于是,就有了这个项目。

  本篇不打算普及canvas的基础,直入主题。

设计实现

面向对象

  这个项目准备用面向对象的思想来实现。原因首先是因为这个项目在6月份的时候已经实现过一次,对功能比较熟悉。其次是之前的代码组织性比较差,没有可扩展性,没有维护性可言。所以,在重新组织代码的过程中,面向对象是一个自然而然的行为。

功能划分

  直观地来看整个项目可以被分为三块:坐标系、两条线段、交点。

由于我们自己绘制了一个坐标系,在描述的过程中容易与canvas本身的坐标系混淆,所以这里定义两个概念:

坐标系逻辑坐标系逻辑坐标:自己绘制的坐标系和其上的坐标点;

实际坐标系实际坐标:canvas画布上的坐标系和其上的坐标点;

  除了坐标系,直线段,交点这些具体内容的实现外,还有三部分需要额外注意。

  第一,从符合人类思维方式的角度上来说,绘制直线段,交点都应该用逻辑坐标来完成。所以应当有一部分功能专门负责实现逻辑坐标实际坐标的相互转换。对于这部分功能而言,我认为就可以把它们收纳到坐标系的代码实现里。由坐标系这个对象来维持这块功能。

  第二,在编写线段的代码时,要考虑到,线段涉及选中拖动放下选中等操作。这些操作对应着canvas元素上的mousedownmousemovemouseup事件。要注意到,这些事件并不是发生在具体图形上的,而是发生在整个canvas画布上的。因此我认为这部分的事件处理函数应当保持一定的独立性,不要过多地涉及具体业务的实现逻辑。我把这部分功能称之为事件层。主要职责就是将鼠标的位置信息传递出去。

  第三,由于canvas的动画效果是需要不停地擦除画布,然后重绘来完成的。并且由于面向对象的缘故,绘制图形的逻辑被分散到各个图形的代码实现里。在canvas元素事件发生时的重绘阶段会有大堆的图形重绘需求,因此很自然地想到我们需要一个中枢来统一地管理这些图形的绘制。

  于是,就可以将上述内容简单地概况为以下几个方面:

  1. 绘制坐标系
  • 建立坐标系上的逻辑值与实际值的相互转换
  1. 绘制需求曲线和供给曲线两条直线
  2. 绘制交点
  3. 实现统一管理图形绘制的中枢
  4. 事件层负责通知选中,拖动,放下选中等操作

零、 前置准备

假定画布的宽高为600x300。

<style>
    #canvas { border: 1px solid #000; }
</style>

<body>
    <canvas id="canvas" width="600" height="300"></canvas>
</body>

<script>
    let canvas = document.getElementById('canvas')
    let ctx = canvas.getContext('2d')
</script>

一、坐标系

  绘制坐标系无非就是画一些线段然后绘制线段上的刻度,思路其实很简单。但是我选用translate将坐标原点平移至画布的左下角,并将y轴的使用方向调整为向上,这样绘制更符合人的思维习惯。(canvas的默认坐标系为原点位于画布左上角,并且x轴向右为正方向,y轴向下为正方向)

class CoordinateSystem {
    constructor({ xAxis, yAxis }) {
        this.xAxis = xAxis
        this.yAxis = yAxis
        this.canvasMargin = 20  // 给坐标系留20的边距
        this.extend = 10 // 用于修饰坐标轴,当在轴上绘制刻度时可以给边界留出点距离
        this.xAxisLength = canvas.width - 2 * this.canvasMargin
        this.yAxisLength = canvas.height - 2 * this.canvasMargin
        this.xRealInterval = this.xAxisLength / xAxis.length
        this.yRealInterval = this.yAxisLength / yAxis.length

        this.create()  // 将来会被移除
    }
    create() {
        ctx.translate(this.canvasMargin, canvas.height - this.canvasMargin) // 改变canvas坐标系
        const amend = x => -x // 配合着canvas的坐标系平移后使用,调整y轴方向向上
        
        // 绘制原点
        ctx.fillText(0, -10, 10)
        
        // x轴
        ctx.beginPath()
        ctx.moveTo(0, 0)
        ctx.lineTo(this.xAxisLength + this.extend, 0)
        ctx.stroke()
        this.xAxis.forEach((point, i) => {
            ctx.beginPath()
            ctx.moveTo(this.xRealInterval * (i + 1), 0)
            ctx.lineTo(this.xRealInterval * (i + 1), amend(5))
            ctx.stroke()

            ctx.save()
            ctx.textAlign = 'center'
            ctx.fillText(point, this.xRealInterval * (i + 1), 10)
            ctx.restore()
        })

        // y轴
        ctx.beginPath()
        ctx.moveTo(0, 0)
        ctx.lineTo(0, amend(this.yAxisLength + this.extend))
        ctx.stroke()
        ctx.save()
        this.yAxis.forEach((point, i) => {
            ctx.beginPath()
            ctx.setLineDash([4,2])
            ctx.strokeStyle = '#dedede'
            ctx.moveTo(0, amend(this.yRealInterval * (i + 1)))
            ctx.lineTo(this.xAxisLength, amend(this.yRealInterval * (i + 1)))
            ctx.stroke()

            ctx.textBaseline = 'middle'
            ctx.textAlign = 'center'
            ctx.fillText(point, -10, amend(this.yRealInterval * (i + 1)))
        })
        ctx.restore()
    }
}
const coordinateSystem = new CoordinateSystem({
    xAxis: [100, 200, 300, 400, 500, 600, 700],
    yAxis: [100, 200, 300, 400]
})

  这样,我们就画好了坐标系 1714976033266.png

逻辑坐标与实际坐标的相互转换

  添加逻辑坐标与实际坐标的相互转换

class CoordinateSystem {
    constructor({xAxis, yAxis}) {
        ...
    }
    create() {
        ...
    }
    logicXToRealX(num) {
        return this.xRealInterval / 100 * num	// 这里的100是逻辑坐标系的间隔
    }
    logicYToRealY(num) {
        return -1 * this.yRealInterval / 100 * num
    }
    realXToLogicX(num) {
        return num * (100 / this.xRealInterval)
    }
    realYToLogicY(num) {
        return num * (100 / this.yRealInterval) 
    }
}

二、需求曲线与供给曲线

  让我们通过这样的形式来创建直线段:

const demandCurrve = new Line([
  [200, 400],
  [600, 100],
])
const supplyCurrve = new Line([
  [200, 100],
  [600, 400]
])

  用一个二元数组来代表一个端点,两个端点确定了一条直线。并且这些端点上的坐标用的是逻辑坐标,这样做可以非常的直观。

  于是乎我们的代码可以写为

class Line {
    constructor(points) {
        this.points = points
        
        this.create(points)  // 将来会被移除
    }
    create() {
        let points = Array.from(this.points)  // 克隆一个数组,不然原数组会被下面的shift消掉
        const startPoint = points.shift()
        ctx.beginPath()
        ctx.moveTo(
            coordinateSystem.logicXToRealX(startPoint[0]),
            coordinateSystem.logicYToRealY(startPoint[1])
        )
        // 这里用forEach来遍历lineTo主要是一种预防性的写法,考虑到这样可以兼容以后万一会出现的折线段
        points.forEach(point => {
            ctx.lineTo(
                coordinateSystem.logicXToRealX(point[0]), 
                coordinateSystem.logicYToRealY(point[1])
            )
        })
        ctx.stroke()
    }
}

1714979082766.png

选中逻辑

  给直线段添加选中逻辑。思路是这样,利用mousedown事件鼠标的位置,判断它到直线的距离,若距离小于某一范围,则判定为选中。

d=Ax0+By0+CA2+B2d = \frac{\left| Ax_0 + By_0 + C\right|}{\sqrt{A^2+B^2}}

  为此,我们需要求出直线的一般式方程:

Ax+By+C=0Ax + By + C = 0

  在代码中用数组的形式来表达this.equation = [A, B, C]

  现在,对于直线我们只有两个端点,如何得到直线的一般式方程呢?可以通过直线的两点式公式来得出:

yy1y2y1=xx1x2x1化简为(y1y2)x+(x2x1)y+x1y2x2y1=0 \frac{y-y_1}{y_2-y_1} = \frac{x-x_1}{x_2-x_1} \\ 化简为 \\ (y_1-y_2)x + (x_2-x_1)y + x_1y_2 - x_2y_1 = 0
class Line {
    constructor(points) {
        this.points = points
        this.equation = this.updateEquation(points)
        this.create(points) // 将来会被移除
    }
    create() {
        ...
    }
    updateEquation(points) {
        if (points.length == 2) {
            const point1 = points[0]
            const point2 = points[1]
            if (this.equation == null) {
                return [
                    point1[1] - point2[1],  // y1 - y2
                    point2[0] - point1[0],  // x2 - x1
                    point1[0] * point2[1] - point2[0] * point1[1] // x1*y2 - x2*y1
                ]
            }
        }
    }
}

  有了直线的方程以后,我们就可以正式地添加选中逻辑,添加一个isSelected标记,并根据它的值来修改绘制逻辑。

class Line {
    constructor(points) {
        this.points = points
        this.equation = this.updateEquation(points)
        this.isSelected = false
        this.create(points) // 将来会被移除
    }
    create() {
        let points = Array.from(this.points)	// 克隆一个数组,不然原数组会被下面的shift消掉
        const startPoint = points.shift()
        ctx.beginPath()
        ctx.moveTo(
            coordinateSystem.logicXToRealX(startPoint[0]),
            coordinateSystem.logicYToRealY(startPoint[1])
        )
        points.forEach(point => {
            ctx.lineTo(
                coordinateSystem.logicXToRealX(point[0]),
                coordinateSystem.logicYToRealY(point[1])
            )
        })
        ctx.save()
        if (this.isSelected) {
            ctx.strokeStyle = '#ffc107'
        }
        ctx.stroke()
        ctx.restore()
    }
    updateEquation(points) {
        ...
    }
    check(mouse) {
        const distance = Math.abs(this.equation[0] * mouse.x + this.equation[1] * mouse.y + this.equation[2]) / Math.sqrt(this.equation[0] ** 2 + this.equation[1] ** 2)
        
        // TODO:如果有线已经被选中了,其他的直线段就不该再能被选中
        
        this.isSelected = distance < 25
    }
}

  此时虽然已添加了直线段的选中逻辑,但是暂时还看不到效果,因为,对它的触发在canvas的mousedown事件处理函数里。我们将在后面再进行补充。

放下选中逻辑

  放下选中的逻辑非常简单,只需要canvas监听mouseup事件,将直线段的isSelected属性置为false即可。

class Line {
  ...
  unselect() {
      this.isSelected = false
  }
  ...
}

  同样地,关于mouseup事件处理函数,也将在后面进行补充。

拖动逻辑

  拖动逻辑的思路也很简单,通过不停地更新直线段上的点,给直线段两个端点的x坐标值加上一个变化量即可。

class Line {
  ...
  updatePoints(deltaX) {
      if (!this.isSelected) return
      
      // 触及左边界,这里图省事,直接用的0和700把x的边界写死
      if (this.points[0][0] <= 0) {
          this.points[0][0] = 0
          if (deltaX < 0) {
              return
          }
      }
      // 触及右边界
      if (this.points[1][0] >= 700) {
          this.points[1][0] = 700
          if (deltaX > 0) {
              return
          }
      }
      // 对线段的两个端点增加x的增量
      this.points.forEach(point => {
          point[0] += deltaX
      })
  }
  ...
}

  同样地,关于mousemove事件处理函数,也将在后面进行补充。

三、交点

  对于交点的绘制要分成两步,一、计算焦点的坐标,二、画图

  之前的直线方程以一般式的形式来记录,即

Ax+By+C=0Ax + By + C = 0

  为了求方程的解,我先将两个方程还原为这种形式

ax+by=mcx+dy=nax + by = m \\ cx + dy = n

  以矩阵表示则为

[abcd][xy]=[mn]\begin{bmatrix}a & b \\c & d \end{bmatrix} \begin{bmatrix}x\\y\\ \end{bmatrix} = \begin{bmatrix}m\\n \end{bmatrix}

  等式两边同乘以一个逆矩阵(2x2矩阵的逆矩阵可以直接由公式得来)

[xy]=1adbc[dbca][mn]\begin{bmatrix}x\\y\\ \end{bmatrix} = \frac{1}{ad-bc}\begin{bmatrix}d & -b \\-c & a \end{bmatrix}\begin{bmatrix}m\\n \end{bmatrix} \\
x=mdnbadbcy=anmcadbcx = \frac{md - nb}{ad-bc}, y = \frac{an-mc}{ad-bc}

  于是,我们便可求出x与y的坐标。

class Intersection {
    constructor(currve1, currve2) {
        this.equation1 = currve1.equation
        this.equation2 = currve2.equation
        
        this.create() // 将来会被移除
    }
    create() {
        const point = this.getPoint()
        const x = coordinateSystem.logicXToRealX(point.x)
        const y = coordinateSystem.logicYToRealY(point.y)
        ctx.beginPath()
        ctx.arc(x, y, 5, 0, Math.PI * 2, true)
        ctx.fill()

        // 绘制虚线
        ctx.moveTo(x, y)
        ctx.lineTo(x, 0)

        ctx.moveTo(x, y)
        ctx.lineTo(0, y)

        ctx.strokeStyle = "#dedede"
        ctx.setLineDash([5, 5])
        ctx.stroke()
        // 绘制文字
        ctx.fillText(point.x.toFixed(2), x, -15)
        ctx.fillText(point.y.toFixed(2), 15, y)
    }
    getPoint() {
        let [a, b, m] = this.equation1
        let [c, d, n] = this.equation2
        const denominator = (a * d - b * c)
        m = -m
        n = -n
        let x = (m * d - n * b) / denominator
        let y = (a * n - m * c) / denominator
        return {x, y}
    }
}

const intersection = new Intersection(demandCurrve, supplyCurrve)

  交点的创建使用了两个线段,并记录下它们的equation属性。由于equation是一个数组,它是一个引用值,因此,当拖动线段发生了更新线段的equation属性后,交点的两个属性equation1equation2也会相应变动,从而实现了交点的更新。

  因此,我们对刚刚的Line做一些修正。

修正Line

  修正updateEquation方法

class Line {
    ...
    updateEquation(points) {
        if (points.length == 2) {
            const point1 = points[0]
            const point2 = points[1]
            if (this.equation == null) {
                return [
                    point1[1] - point2[1],	// y1 - y2
                    point2[0] - point1[0], 	// x2 - x1
                    point1[0] * point2[1] - point2[0] * point1[1] // x1*y2 - x2*y1
                ]
            } else {
                this.equation[0] = point1[1] - point2[1]
                this.equation[1] = point2[0] - point1[0]
                this.equation[2] = point1[0] * point2[1] - point2[0] * point1[1]
            }
        }
    }
    ...
}

  更新坐标点时也应该更新方程

class Line {
    ...
    updatePoints(deltaX) {
        ...
        this.updateEquation(this.points)
    }
    ...
}

四、绘制中枢

  由于canvas的动画更新都需要擦除画布,然后重绘,因此我们每次重绘的时候都需要绘制坐标系,绘制两条线段,绘制交点。这样会有些繁琐,因此,我将它们都集中到一处,进行统一的管理。

  我给这一功能起了一个形象的名字——画家painter。   

class Painter {
    constructor() {
        this.works = []
    }
    prepare(element) {
        this.works.push(element)
    }
    paint() {
        ctx.save()
        ctx.clearRect(0, 0, canvas.width, canvas.height)
        this.works.forEach(element => {
            element.create()
        })
        ctx.restore()
    }
}
const painter = new Painter()

  将图形都添加到画家中

painter.prepare(coordinateSystem)
painter.prepare(demandCurrve)
painter.prepare(supplyCurrve)
painter.prepare(intersection)
painter.paint()

  这也就是为什么之前的图形原型里都有一个create方法。可以将之前的各个图形里的constructor方法里的create函数去掉了。

修正Line

  在check方法里,为了防止一下选中两条直线,之前留下了一个TODO,现在可以补上了。

class Line {
    ...
    check(mouse) {
        const distance =  Math.abs(this.equation[0] * mouse.x + this.equation[1] * mouse.y + this.equation[2]) / Math.sqrt(this.equation[0] ** 2 + this.equation[1] ** 2)
        let lines = painter.works.filter(ele => ele.constructor.name == 'Line')
        for (let i = 0; i < lines.length; i++) {
            if (lines[i].isSelected) return // 如果有线已经被选中了,就不再能选中
        }
        this.isSelected = distance < 25
    }
    ...
}

事件层

mousedown事件

canvas.addEventListener('mousedown', ({ offsetX, offsetY }) => {
    // 鼠标落入逻辑坐标系外,不予选中
    if (
        offsetX < coordinateSystem.canvasMargin || offsetX > (canvas.width - coordinateSystem.canvasMargin)	// 坐标系的左右边界
        || offsetY < coordinateSystem.canvasMargin || offsetY > (canvas.height - coordinateSystem.canvasMargin) // 坐标系的上下边界
    ) return

    // 逻辑鼠标位置
    const mouseLogicX = coordinateSystem.realXToLogicX(offsetX - coordinateSystem.canvasMargin)
    const mouseLogicY = coordinateSystem.realYToLogicY(canvas.height - offsetY - coordinateSystem.canvasMargin)
    demandCurrve.check({ x: mouseLogicX, y: mouseLogicY })
    supplyCurrve.check({ x: mouseLogicX, y: mouseLogicY })

    // 更新完图形后重绘
    painter.paint()
})

mouseup事件

canvas.addEventListener('mouseup', () => {
    demandCurrve.unselect()
    supplyCurrve.unselect()
    painter.paint()
})

mousemove事件

canvas.addEventListener('mousemove', e => {
    if (demandCurrve.isSelected || supplyCurrve.isSelected) {
        const mouseLogicX = coordinateSystem.realXToLogicX(e.movementX) // this.axisMargin
        demandCurrve.updatePoints(mouseLogicX)
        supplyCurrve.updatePoints(mouseLogicX)
        painter.paint()
    }
})

  至此,就完成了线段的平移效果。 动画.gif

结语

  这其实已经是我第二遍来实现这个功能了,并采用了一个面向对象的方实现式。这样做之后代码就显得非常的紧凑,不像之前那么零散。但是不足之处也很明显,就是显得思维不那么连贯。毕竟这是一个已经经过抽象了的东西。它有更好的结构,但是消除了那些人类从开始到结果的一连串思路。鉴于篇幅的原因,我没有保留下那些人类思考过程的痕迹。

  在这个过程中同时我也发现无论是选中线段,还是计算交点,依赖的都是直线方程,这东西在表达上与计算上都存在着不方便。我想,也许可以借助线性代数里的内容,来帮助我们更好地完成这块功能。比如用向量的形式来替代直线方程,我猜想也有方法去计算点到向量的距离,和计算两个向量的交点。

  这些东西,我现在还不会,给未来留个期待吧。

全部代码

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        #canvas { border: 1px solid #000; }
    </style>
</head>
<body>
    <canvas id="canvas" width="600" height="300"></canvas>
</body>

<script>
    let canvas = document.getElementById('canvas')
    let ctx = canvas.getContext('2d')

    class CoordinateSystem {
        constructor({ xAxis, yAxis }) {
            this.xAxis = xAxis
            this.yAxis = yAxis
            this.canvasMargin = 20  // 给坐标系留20的边距
            this.extend = 10 // 用于修饰坐标轴,当在轴上绘制刻度时可以给边界留出点距离
            this.xAxisLength = canvas.width - 2 * this.canvasMargin
            this.yAxisLength = canvas.height - 2 * this.canvasMargin
            this.xRealInterval = this.xAxisLength / xAxis.length
            this.yRealInterval = this.yAxisLength / yAxis.length

        }
        create() {
            ctx.translate(this.canvasMargin, canvas.height - this.canvasMargin) // 改变canvas坐标系
            const amend = x => -x // 配合着canvas的坐标系平移后使用,调整y轴方向向上
            
            // 绘制原点
            ctx.fillText(0, -10, 10)
            
            // x轴
            ctx.beginPath()
            ctx.moveTo(0, 0)
            ctx.lineTo(this.xAxisLength + this.extend, 0)
            ctx.stroke()
            this.xAxis.forEach((point, i) => {
                ctx.beginPath()
                ctx.moveTo(this.xRealInterval * (i + 1), 0)
                ctx.lineTo(this.xRealInterval * (i + 1), amend(5))
                ctx.stroke()

                ctx.save()
                ctx.textAlign = 'center'
                ctx.fillText(point, this.xRealInterval * (i + 1), 10)
                ctx.restore()
            })

            // y轴
            ctx.beginPath()
            ctx.moveTo(0, 0)
            ctx.lineTo(0, amend(this.yAxisLength + this.extend))
            ctx.stroke()
            ctx.save()
            this.yAxis.forEach((point, i) => {
                ctx.beginPath()
                ctx.setLineDash([4,2])
                ctx.strokeStyle = '#dedede'
                ctx.moveTo(0, amend(this.yRealInterval * (i + 1)))
                ctx.lineTo(this.xAxisLength, amend(this.yRealInterval * (i + 1)))
                ctx.stroke()

                ctx.textBaseline = 'middle'
                ctx.textAlign = 'center'
                ctx.fillText(point, -10, amend(this.yRealInterval * (i + 1)))
            })
            ctx.restore()
        }
        logicXToRealX(num) {
            return this.xRealInterval / 100 * num	// 这里的100是逻辑坐标系的间隔
        }
        logicYToRealY(num) {
            return -1 * this.yRealInterval / 100 * num
        }
        realXToLogicX(num) {
            return num * (100 / this.xRealInterval)
        }
        realYToLogicY(num) {
            return num * (100 / this.yRealInterval) 
        }
    }
    const coordinateSystem = new CoordinateSystem({
        xAxis: [100, 200, 300, 400, 500, 600, 700],
        yAxis: [100, 200, 300, 400]
    })

    class Line {
        constructor(points) {
            this.points = points
            this.equation = this.updateEquation(points)
            this.isSelected = false
        }
        create() {
            let points = Array.from(this.points)  // 克隆一个数组,不然原数组会被下面的shift消掉
            const startPoint = points.shift()
            ctx.beginPath()
            ctx.moveTo(
                coordinateSystem.logicXToRealX(startPoint[0]),
                coordinateSystem.logicYToRealY(startPoint[1])
            )
            // 这里用forEach来遍历lineTo主要是一种预防性的写法,考虑到这样可以兼容以后万一会出现的折线段
            points.forEach(point => {
                ctx.lineTo(
                    coordinateSystem.logicXToRealX(point[0]), 
                    coordinateSystem.logicYToRealY(point[1])
                )
            })
            ctx.save()
            if (this.isSelected) {
                ctx.strokeStyle = '#ffc107'
            }
            ctx.stroke()
            ctx.restore()
        }
        updateEquation(points) {
            if (points.length == 2) {
                const point1 = points[0]
                const point2 = points[1]
                if (this.equation == null) {
                    return [
                        point1[1] - point2[1],  // y1 - y2
                        point2[0] - point1[0],  // x2 - x1
                        point1[0] * point2[1] - point2[0] * point1[1] // x1*y2 - x2*y1
                    ]
                } else {
                    this.equation[0] = point1[1] - point2[1]
                    this.equation[1] = point2[0] - point1[0]
                    this.equation[2] = point1[0] * point2[1] - point2[0] * point1[1]
                }
            }
        }
        check(mouse) {
            const distance = Math.abs(this.equation[0] * mouse.x + this.equation[1] * mouse.y + this.equation[2]) / Math.sqrt(this.equation[0] ** 2 + this.equation[1] ** 2)

            let lines = painter.works.filter(ele => ele.constructor.name == 'Line')
            for (let i = 0; i < lines.length; i++) {
                if (lines[i].isSelected) return // 如果有线已经被选中了,就不再能选中
            }
            
            this.isSelected = distance < 25
        }
        unselect() {
            this.isSelected = false
        }
        updatePoints(deltaX) {
            if (!this.isSelected) return
            
            // 触及左边界,这里图省事,直接用的0和700把x的边界写死
            if (this.points[0][0] <= 0) {
                this.points[0][0] = 0
                if (deltaX < 0) {
                    return
                }
            }
            // 触及右边界
            if (this.points[1][0] >= 700) {
                this.points[1][0] = 700
                if (deltaX > 0) {
                    return
                }
            }
            // 对线段的两个端点增加x的增量
            this.points.forEach(point => {
                point[0] += deltaX
            })
            this.updateEquation(this.points)
        }
    }
    const demandCurrve = new Line([
        [200, 400],
        [600, 100],
    ])
    const supplyCurrve = new Line([
        [200, 100],
        [600, 400]
    ])
    class Intersection {
        constructor(currve1, currve2) {
            this.equation1 = currve1.equation
            this.equation2 = currve2.equation
            
        }
        create() {
            const point = this.getPoint()
            const x = coordinateSystem.logicXToRealX(point.x)
            const y = coordinateSystem.logicYToRealY(point.y)
            ctx.beginPath()
            ctx.arc(x, y, 5, 0, Math.PI * 2, true)
            ctx.fill()

            // 绘制虚线
            ctx.moveTo(x, y)
            ctx.lineTo(x, 0)

            ctx.moveTo(x, y)
            ctx.lineTo(0, y)

            ctx.strokeStyle = "#dedede"
            ctx.setLineDash([5, 5])
            ctx.stroke()
            // 绘制文字
            ctx.fillText(point.x.toFixed(2), x, -15)
            ctx.fillText(point.y.toFixed(2), 15, y)
        }
        getPoint() {
            let [a, b, m] = this.equation1
            let [c, d, n] = this.equation2
            const denominator = (a * d - b * c)
            m = -m
            n = -n
            let x = (m * d - n * b) / denominator
            let y = (a * n - m * c) / denominator
            return {x, y}
        }
    }

    const intersection = new Intersection(demandCurrve, supplyCurrve)

    class Painter {
        constructor() {
            this.works = []
        }
        prepare(element) {
            this.works.push(element)
        }
        paint() {
            ctx.save()
            ctx.clearRect(0, 0, canvas.width, canvas.height)
            this.works.forEach(element => {
                element.create()
            })
            ctx.restore()
        }
    }
    const painter = new Painter()
    painter.prepare(coordinateSystem)
    painter.prepare(demandCurrve)
    painter.prepare(supplyCurrve)
    painter.prepare(intersection)
    painter.paint()

    canvas.addEventListener('mousedown', ({ offsetX, offsetY }) => {
        // 鼠标落入逻辑坐标系外,不予选中
        if (
            offsetX < coordinateSystem.canvasMargin || offsetX > (canvas.width - coordinateSystem.canvasMargin)	// 坐标系的左右边界
            || offsetY < coordinateSystem.canvasMargin || offsetY > (canvas.height - coordinateSystem.canvasMargin) // 坐标系的上下边界
        ) return
        // 逻辑鼠标位置
        const mouseLogicX = coordinateSystem.realXToLogicX(offsetX - coordinateSystem.canvasMargin)
        const mouseLogicY = coordinateSystem.realYToLogicY(canvas.height - offsetY - coordinateSystem.canvasMargin)
        demandCurrve.check({ x: mouseLogicX, y: mouseLogicY })
        supplyCurrve.check({ x: mouseLogicX, y: mouseLogicY })

        // 更新完图形后重绘
        painter.paint()
    })

    canvas.addEventListener('mouseup', () => {
        demandCurrve.unselect()
        supplyCurrve.unselect()
        painter.paint()
    })

    canvas.addEventListener('mousemove', e => {
        if (demandCurrve.isSelected || supplyCurrve.isSelected) {
            const mouseLogicX = coordinateSystem.realXToLogicX(e.movementX) // this.axisMargin
            demandCurrve.updatePoints(mouseLogicX)
            supplyCurrve.updatePoints(mouseLogicX)
            painter.paint()
        }
    })
</script>
</html>