成果展示
(先把代码部分隐藏后再尝试demo,拖动底部滚动条会使demo出现一些问题)
前言
在经济学中,供需关系是一个简单但十分强大的分析工具,不过它常常以图片的形式来展示,我想,能不能借助前端领域强大的交互能力,来赋予它更好的表达效果呢。于是,就有了这个项目。
本篇不打算普及canvas的基础,直入主题。
设计实现
面向对象
这个项目准备用面向对象的思想来实现。原因首先是因为这个项目在6月份的时候已经实现过一次,对功能比较熟悉。其次是之前的代码组织性比较差,没有可扩展性,没有维护性可言。所以,在重新组织代码的过程中,面向对象是一个自然而然的行为。
功能划分
直观地来看整个项目可以被分为三块:坐标系、两条线段、交点。
由于我们自己绘制了一个坐标系,在描述的过程中容易与canvas本身的坐标系混淆,所以这里定义两个概念:
坐标系或逻辑坐标系和逻辑坐标:自己绘制的坐标系和其上的坐标点;
实际坐标系和实际坐标:canvas画布上的坐标系和其上的坐标点;
除了坐标系,直线段,交点这些具体内容的实现外,还有三部分需要额外注意。
第一,从符合人类思维方式的角度上来说,绘制直线段,交点都应该用逻辑坐标来完成。所以应当有一部分功能专门负责实现逻辑坐标与实际坐标的相互转换。对于这部分功能而言,我认为就可以把它们收纳到坐标系的代码实现里。由坐标系这个对象来维持这块功能。
第二,在编写线段的代码时,要考虑到,线段涉及选中、拖动、放下选中等操作。这些操作对应着canvas元素上的mousedown
,mousemove
,mouseup
事件。要注意到,这些事件并不是发生在具体图形上的,而是发生在整个canvas画布上的。因此我认为这部分的事件处理函数应当保持一定的独立性,不要过多地涉及具体业务的实现逻辑。我把这部分功能称之为事件层。主要职责就是将鼠标的位置信息传递出去。
第三,由于canvas的动画效果是需要不停地擦除画布,然后重绘来完成的。并且由于面向对象的缘故,绘制图形的逻辑被分散到各个图形的代码实现里。在canvas元素事件发生时的重绘阶段会有大堆的图形重绘需求,因此很自然地想到我们需要一个中枢来统一地管理这些图形的绘制。
于是,就可以将上述内容简单地概况为以下几个方面:
- 绘制坐标系
- 建立坐标系上的逻辑值与实际值的相互转换
- 绘制需求曲线和供给曲线两条直线
- 绘制交点
- 实现统一管理图形绘制的中枢
- 事件层负责通知选中,拖动,放下选中等操作
零、 前置准备
假定画布的宽高为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]
})
这样,我们就画好了坐标系
逻辑坐标与实际坐标的相互转换
添加逻辑坐标与实际坐标的相互转换
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()
}
}
选中逻辑
给直线段添加选中逻辑。思路是这样,利用mousedown
事件鼠标的位置,判断它到直线的距离,若距离小于某一范围,则判定为选中。
为此,我们需要求出直线的一般式方程:
在代码中用数组的形式来表达this.equation = [A, B, C]
现在,对于直线我们只有两个端点,如何得到直线的一般式方程呢?可以通过直线的两点式公式来得出:
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
事件处理函数,也将在后面进行补充。
三、交点
对于交点的绘制要分成两步,一、计算焦点的坐标,二、画图
之前的直线方程以一般式的形式来记录,即
为了求方程的解,我先将两个方程还原为这种形式
以矩阵表示则为
等式两边同乘以一个逆矩阵(2x2矩阵的逆矩阵可以直接由公式得来)
于是,我们便可求出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
属性后,交点的两个属性equation1
,equation2
也会相应变动,从而实现了交点的更新。
因此,我们对刚刚的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()
}
})
完
至此,就完成了线段的平移效果。
结语
这其实已经是我第二遍来实现这个功能了,并采用了一个面向对象的方实现式。这样做之后代码就显得非常的紧凑,不像之前那么零散。但是不足之处也很明显,就是显得思维不那么连贯。毕竟这是一个已经经过抽象了的东西。它有更好的结构,但是消除了那些人类从开始到结果的一连串思路。鉴于篇幅的原因,我没有保留下那些人类思考过程的痕迹。
在这个过程中同时我也发现无论是选中线段,还是计算交点,依赖的都是直线方程,这东西在表达上与计算上都存在着不方便。我想,也许可以借助线性代数里的内容,来帮助我们更好地完成这块功能。比如用向量的形式来替代直线方程,我猜想也有方法去计算点到向量的距离,和计算两个向量的交点。
这些东西,我现在还不会,给未来留个期待吧。
全部代码
<!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>