用 React 制作线性代数教程示例:网格与箭头

本文是“JavaScript 线性代数”教程的一部分。

最近我撰写了这个线性代数系列的开篇之作。在新篇开始动笔前,我有了一个想法:使用 React 开发一个项目,来为这个系列的所有示例提供可视化功能一定很好玩!本系列的所有代码都存放于此 GitHub 仓库,本文相关代码的提交记录位于此处

目标

在本系列刚开始写作时,只有一个章节涉及了向量的基本运算。所以,目前实现一个能渲染二维坐标网格以及能将向量可视化为箭头的组件就够用了。本文最后做出的效果如下图所示,你也可以在此处进行体验。

二维空间中的基本向量运算

创建 React 项目

其实已经有关于创建 React 项目的最佳实践指南文章可供参考,不过在本文中,我们将尽可能减少依赖的库,并简化对项目的配置。

1
2
3
create-react-app linear-algebra-demo
cd linear-algebra-demo
npm install --save react-sizeme styled-components

上面的脚本安装了两个库。第一个库 react-sizeme 可以实现当窗体大小发生变化时,重新渲染网格组件。第二个库 styled-components 则能让我们更轻松地编写组件的样式。此外,要用到我们正在开发的 linear-algebra 库,需要在 package.json 中进行如下引用:

1
2
3
4
"dependencies": {
"linear-algebra": "file:../library",
...
}

项目结构

项目结构

本系列为每个示例都在 views 目录中创建了各自的组件。我们在 index.js 中导出一个以示例名称为键、以对应组件为值的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
import { default as VectorLength } from './vector-length'
import { default as VectorScale } from './vector-scale'
import { default as VectorsAddition } from './vectors-addition'
import { default as VectorsSubtraction } from './vectors-subtraction'
import { default as VectorsDotProduct } from './vectors-dot-product'

export default {
'vectors: addition': VectorsAddition,
'vectors: subtraction': VectorsSubtraction,
'vectors: length': VectorLength,
'vectors: scale': VectorScale,
'vectors: dot product': VectorsDotProduct
}

接着在 Main 组件中导入该对象,并在菜单中展示出所有的键。当用户通过菜单选择示例后,更新组件状态,并渲染新的 view

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import React from 'react'
import styled from 'styled-components'

import views from './views'
import MenuItem from './menu-item'

const Container = styled.div`
...
`

const Menu = styled.div`
...
`

class Main extends React.Component {
constructor(props) {
super(props)
this.state = {
view: Object.keys(views)[0]
}
}

render() {
const { view } = this.state
const View = views[view]
const viewsNames = Object.keys(views)
const MenuItems = () =>
viewsNames.map(name => (
<MenuItem
key={name}
selected={name === view}
text={name}
onClick={() => this.setState({ view: name })}
/>
))
return (
<Container>
<View />
<Menu>
<MenuItems />
</Menu>
</Container>
)
}
}

export default Main

网格组件

为了在之后的示例中渲染向量和其它内容,我们设计了一个功能强大的组件,这个组件需要有这么一种投影功能:将我们熟知的直角坐标系(原点在中间,y 轴正向朝上)投影到 SVG 坐标系(原点在左上角,y 轴正向朝下)中。

1
2
3
4
5
6
7
8
9
10
this.props.updateProject(vector => {
// 在 vector 类中没有任何用于缩放的方法,因此在这里进行计算:
const scaled = vector.scaleBy(step)
const withNegatedY = new Vector(
scaled.components[0],
-scaled.components[1]
)
const middle = getSide(size) / 2
return withNegatedY.add(new Vector(middle, middle))
})

为了捕获到网格组件容器的大小变动,我们使用 react-size 库提供的函数将这个组件包装起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
...
import { withSize } from 'react-sizeme'
...

class Grid extends React.Component {
updateProject = (size, cells) => {
const step = getStepLen(size, cells)
this.props.updateProject(() => /...)
}

componentWillReceiveProps({ size, cells }) {
if (this.props.updateProject) {
const newStepLen = getStepLen(size, cells)
const oldStepLen = getStepLen(this.props.size, cells)
if (newStepLen !== oldStepLen) {
this.updateProject(size, cells)
}
}
}

componentDidMount() {
if (this.props.updateProject) {
this.updateProject(this.props.size, this.props.cells)
}
}
}

export default withSize({ monitorHeight: true })(Grid)

为了便于在不同的示例中使用这个网格组件,我们编写了一个 GridExample 组件,它可以接收两个参数:一个用于渲染信息(例如向量的名称)的函数 renderInformation,以及一个用于在网格上呈现内容(如后面的箭头组件)的函数 renderGridContent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
...
import Grid from './grid'
...
class Main extends React.Component {
constructor(props) {
super(props)
this.state = {
project: undefined
}
}
render() {
const { project } = this.state
const { renderInformation, renderGridContent } = this.props
const Content = () => {
if (project && renderGridContent) {
return renderGridContent({ project })
}
return null
}
const Information = () => {
if (renderInformation) {
return renderInformation()
}
return null
}
return (
<Container>
<Grid cells={10} updateProject={project => this.setState({ project })}>
<Content />
</Grid>
<InfoContainer>
<Information />
</InfoContainer>
</Container>
)
}
}

export default Main

这样就能在 view 中使用这个组件了。下面以向量的加法为例测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import React from 'react'
import { withTheme } from 'styled-components'
import { Vector } from 'linear-algebra/vector'

import GridExample from '../grid-example'
import Arrow from '../arrow'
import VectorView from '../vector'

const VectorsAddition = ({ theme }) => {
const one = new Vector(0, 5)
const other = new Vector(6, 2)
const oneName = 'v⃗'
const otherName = 'w⃗'
const oneColor = theme.color.green
const otherColor = theme.color.red
const sum = one.add(other)
const sumColor = theme.color.blue
const sumText = `${oneName} + ${otherName}`

const renderInformation = () => (
<>
<VectorView components={one.components} name={oneName} color={oneColor} />
<VectorView
components={other.components}
name={otherName}
color={otherColor}
/>
<VectorView components={sum.components} name={sumText} color={sumColor} />
</>
)
const renderGridContent = ({ project }) => (
<>
<Arrow project={project} vector={one} text={oneName} color={oneColor} />
<Arrow
project={project}
vector={other}
text={otherName}
color={otherColor}
/>
<Arrow project={project} vector={sum} text={sumText} color={sumColor} />
</>
)
const props = { renderInformation, renderGridContent }

return <GridExample {...props} />
}

export default withTheme(VectorsAddition)

箭头组件

箭头组件由 3 个 SVG 元素组成:line 用于显示箭头的线、polygon 用于显示箭头的头、text 用于显示向量名称。此外,我们需要接收 project 函数,用于将箭头放在网格中正确的位置上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import React from 'react'
import styled from 'styled-components'
import { Vector } from 'linear-algebra/vector'

const Arrow = styled.line`
stroke-width: 2px;
stroke: ${p => p.color};
`

const Head = styled.polygon`
fill: ${p => p.color};
`

const Text = styled.text`
font-size: 24px;
fill: ${p => p.color};
`

export default ({ vector, text, color, project }) => {
const direction = vector.normalize()

const headStart = direction.scaleBy(vector.length() - 0.6)
const headSide = new Vector(
direction.components[1],
-direction.components[0]
).scaleBy(0.2)
const headPoints = [
headStart.add(headSide),
headStart.subtract(headSide),
vector
]
.map(project)
.map(v => v.components)

const projectedStart = project(new Vector(0, 0))
const projectedEnd = project(vector)

const PositionedText = () => {
if (!text) return null
const { components } = project(vector.withLength(vector.length() + 0.2))
return (
<Text color={color} x={components[0]} y={components[1]}>
{text}
</Text>
)
}
return (
<g>
<Arrow
color={color}
x1={projectedStart.components[0]}
y1={projectedStart.components[1]}
x2={projectedEnd.components[0]}
y2={projectedEnd.components[1]}
/>
<Head color={color} points={headPoints} />
<PositionedText />
</g>
)
}

通过结合 ReactSVG 可以做更多有意思的事。在本系列的后面章节中,我们会给这个可视化示例添加更多的功能。最后推荐另一篇类似的文章:使用 ReactSVG 制作复杂的条形图