React-和-D3-js-集成教程-三-

113 阅读34分钟

React 和 D3.js 集成教程(三)

原文:Integrating D3.js with React

协议:CC BY-NC-SA 4.0

五、集成状态管理

在前一章中,我向您展示了如何使用 React 函数和类组件以及 D3 创建简单的图表。在最后一个例子中,我们通过集成 D3 和 React 以及添加其他 React 库(如 Material-UI 和 Jest)创建了一个直方图。

在直方图中,我们从App.tsx父组件中检索数据,因此数据可以在多个组件之间共享。

在这一章中,我们将进一步发展这个图表。我们将把 React 状态管理集成到这个组合中,这样我们就可以在我们的应用中共享我们的数据,甚至可以在不是来自同一个父组件的多个组件中共享我们的数据。这将使用状态管理来完成。

在这一章中,您将了解由脸书引入的状态管理体系结构 Flux,然后您将了解来自脸书的新的实验性状态管理体系结构反冲。在这个过程中,我将向您展示如何向图表添加结构,这可以帮助您构建更复杂的图表,我们甚至将集成一个使用相同数据的表列表组件。

状态管理

数据本身的一个变化听起来无关紧要,对于您的组件来说,实现和管理足够简单。那么为什么我们需要一个状态管理库来完成这个任务呢?

通俗地说,状态管理帮助组织你的 app 的数据和用户交互,直到用户的会话结束。它还有助于确保您的代码不会因为添加了更多功能而变得混乱。

它使测试变得更加容易,并确保代码不依赖于特定的开发技术,并且可以扩展。

Note

状态管理是一种在用户会话结束前维护应用状态的方法。

如果你看看我们在前几章创建的图表,我们没有问题,也不需要设计模式来帮助我们管理数据移动。事实上,实现一个架构来控制我们的数据移动,对于这样简单的功能来说,可能会被视为矫枉过正。我们使用的状态是一旦接收到数据就保存它,并且一切正常。

然而,随着我们的代码增长,我们的应用变得更大,有多个开发人员和设计人员,我们需要某种架构来帮助处理数据移动,并实施最佳实践来帮助管理我们的代码,以便它不会随着每次更改而中断。

事实上,脸书遇到了这些挑战,并寻找解决这些问题的方法。

流量

脸书团队首先尝试了一些已经存在的工具。他们首先实现了模型-视图-控制器(MVC)模式;然而,他们发现随着越来越多的特性被添加,架构模式会导致问题,并且由于代码经常出错,一部分代码更难维护。

React 团队在使用 MVC 模式分离关注点和管理前端状态时遇到的挑战最终导致了 Flux 的产生。

重要的是要知道 Flux 状态管理正在被逐步淘汰,项目处于维护模式。还有许多更复杂的选择。

MVC 解决什么?

在复杂的应用中,MVC 模式是分离关注点的常见实践。

  • 模型:模型是应用中使用的数据。

  • 视图:视图是前端的表示层。

  • 控制器:这是绑定模型和视图的胶水。

脸书团队解释说,当开发人员尝试使用 MVC 时,他们遇到了可能导致循环的数据流问题,这可能会导致应用崩溃,因为它会成为内存泄漏(嵌套更新的级联效应),并不断更新渲染。

这些挑战被脸书团队解决了,他们推出了一个名为 Flux 的架构,最近又推出了一个名为反冲的实验库。

Note

Flux 是一个用于构建用户界面的应用架构。 https://facebook.github.io/flux/

“Flux 是脸书用来构建客户端 web 应用的应用架构。它通过利用单向数据流来补充 React 的可组合视图组件。它更多的是一种模式,而不是一个正式的框架。”

https://facebook.github.io/flux/docs/in-depth-overview

从我个人的经验来看,我曾经使用过许多大大小小的基于 MVC 的应用,我不得不有点不同意脸书团队的观点。其中一些项目是构建在 MVC 基础上的非常复杂的企业级应用,通过实施良好的习惯,基于 MVC 的应用可以无缝地工作。也就是说,在许多 MVC 框架的实现中涉及到大量的样板代码,并且经常需要进行代码审查来加强良好的习惯并保持关注点的分离。

脸书的 Flux 架构确实简化了分离关注点的过程,并且是一种新鲜的、受欢迎的状态管理替代方案,同时保持了较少的样板代码和松散耦合的组件。您可以在此了解更多关于 Flux 的信息:

https://github.com/facebook/flux

https://facebook.github.io/flux/

Flux 正在被淘汰,但是还有其他几个状态管理库。

报应

Redux(和 Redux 工具包)是编写本文时最流行的状态管理库。如果你想了解更多关于 Redux 的知识,我推荐你在 https://www.apress.com/gp/book/9781484266953 购买我的 React 和 Libraries 书,或者在 https://medium.com/react-courses/instance-learn-react-redux-4-redux-toolkit-in-minutes-a-2020-reactjs-16-tutorial-9adaec6f2836 阅读我的文章。

与 Redux 或 Redux 工具包不同,使用反冲,不需要设置复杂的中间件、连接您的组件或使用任何其他东西来使 React 组件相互之间很好地配合。

Did you know?

反冲库仍处于实验阶段,但它已经获得了一些非凡的人气,甚至超过了 Redux。反冲库在 GitHub 上有接近 10000 颗星,超过了 Redux 工具包的 4100 颗星!

我和许多其他人都认为,反冲将成为 React 中状态管理的标准,这是比继续利用 Redux 工具包 进行中间件开发更好的投资。

但是,请记住,了解 Redux 工具包仍然是很好的,因为您可能会参与到使用 Redux 的项目中。此外,反冲仍然是实验性的,因为这本书的写作,所以它不是为心脏的微弱。

为了了解反冲,我们将重构我们在前一章创建的Histogram组件。

反冲是脸书改变生活的状态管理实验,正在席卷 React 开发者社区。后坐力团队说的很好:

“后坐力的工作方式和思考方式都像 React。添加一些到您的应用中,获得快速灵活的共享状态。”

反冲是在有许多状态管理库的时候开发和发布的,所以你可能会问为什么我们还需要另一个状态管理来共享我们的应用状态。使用反冲可以更好、更容易地在多个组件之间共享状态和设置中间件吗?快速回答是肯定的!

如果你需要做的只是全局存储值,你选择的任何库都可以;然而,当您开始做更复杂的事情时,事情就变得复杂了,比如异步调用,或者试图让您的客户端与您的服务器状态同步,或者反向用户交互。

理想情况下,我们希望我们的 React 组件尽可能纯净,并且数据管理需要在没有副作用的情况下通过 React 钩子。我们还希望“真正的”DOM 为了性能而尽可能少地改变。

保持组件松散耦合对于开发人员来说总是一个好地方,因此拥有一个与 React 很好集成的库是对 React 库的一个很好的补充,因为它将 React 与 Angular 等其他顶级 JavaScript 框架放在一起。

拥有固态管理库将有助于 React 应用服务于企业级复杂应用,以及处理前端和中间层的复杂操作。反冲简化了状态管理,我们只需要创建两个成分:原子和选择器( https://recoiljs.org/docs/introduction/core-concepts/ )。

原子是物体。它们是组件可以订阅的状态单元。反冲让我们创建一个从这些原子(共享状态)流向组件的数据流图。

选择器是纯粹的函数,允许你同步或者异步地转换状态。

请记住,您不必创建原子和选择器。您可以只使用没有任何原子的选择器,也可以创建没有选择器的原子。

为了展示如何开始反冲,我将把这个过程分为两个步骤。

  • 步骤 1 :实施反冲

  • 第二步:重构视图层

为了开始,我们通常首先需要安装反冲(yarn add recoil)。在撰写本文时,反冲的版本是 0.1.2,但在您阅读本章时,这种情况将会改变。然而,我们的 CRA MHL 模板已经包括反冲和 Redux,所以反冲已经设置好了,不需要您的任何安装。

历史价格状态

我们开始吧。

ts:设置我们的数据类型

在我们的直方图中,我们创建了types.ts。该类保存我们在图表中使用的类型。这种类型的架构很棒,因为它允许我们复制我们的组件,并在任何我们想要的地方重用它,保持我们的代码松散耦合。

然而,反冲也需要一个定义。我可以只导入 types 类,但是这会在我们的状态和图表之间创建一个组合。

如果我有多个使用相同数据的图表,这就不理想了,因为我们需要导入类型。

Note

我的决定是创建一个模型类,我可以用它来初始化我的对象,并为 price 对象提供一个接口。这种设计不是强制性的;这取决于你需要什么。如果你能删除代码,并且一切正常,易于理解,那就继续删除代码吧。我只是让你在这里开始。

看一看:

// src/model/historicalPriceObject.ts

export interface historicalPriceObject {
  price: number
}

export const initHistoricalPrice = (): historicalPriceObject => ({
  price: 0,
})

Note

就像他们说的,有很多方法可以剥一只猫的皮。每种方法都有利弊;你需要判断这是否适合你。

index.ts:易于访问

接下来,建立一个索引文件,以便于访问我们的类型。

// src/model/index.ts
export * from './historicalPriceObject'

历史价格原子:共享状态

现在我有了模型对象,我可以创建反冲原子了。

正如我提到的,反冲原子是物体。它们是组件可以订阅的状态单元。反冲让我们创建一个从这些原子(共享状态)流向组件的数据流图。我可以在我的 Atom 中使用我的模型,如下面的代码所示。我们从反冲库中导入原子,以及我们在上一步中创建的模式initHistoricalPrice来设置默认值。

// src/recoil/atoms/historicalPriceAtoms.ts

import { atom } from 'recoil'
import { initHistoricalPrice } from '../../model'

export const historicalPriceState = atom({
  key: 'historicalPriceState',
  default: initHistoricalPrice(),
})

反冲中的键应该是唯一的键。一个好的做法是将密钥命名为与文件名相同的名称。因为所有的原子可以存在于同一个目录中,src/recoil/atoms/,我们不能有相同名称的重复文件名,所以这将确保我们的键是唯一的。

ts:转变我们的异步状态

反冲的第二个要素是选择器。选择器是纯函数,允许您同步或异步地转换状态。在我们的例子中,我们可以使用相同的d3.dsv代码从 CSV 文件中检索价格。

就像反冲原子一样,我们的选择器需要一个惟一的键,我正在进行一个异步调用并设置一个承诺,因为我不想停止我的代码。

一旦检索到数据,我将它转换为我的类型historicalPriceObject[],并使用 promise resolve 返回数据。

看一看:

//src/model/historicalPriceSelectors.ts

import { selector } from 'recoil'
import * as d3 from 'd3'

import { historicalPriceObject } from '../../model'

export const getHistoricalPriceData = selector({
  key: 'getHistoricalPriceData',
  get: async () => {
    return getData()
  },
})

const getData = () =>
  new Promise((resolve) =>
    d3
      .dsv(',', '/data/historicalPrice.csv', function results(d) {
        return {
          price: d.open,
        }
      })
      .then(function results(data) {
        resolve((data as unknown) as historicalPriceObject[])
      })
  )

注意,TS 不知道我们有什么类型的数据,所以我将把我的数据强制转换为historicalPriceObject

(data as unknown) as historicalPriceObject[]

HistogramWidget:自定义组件

在前一章中,我们将App.tsx作为检索数据的父组件,将Histogram.tsx作为图表组件。

我要做的是添加另一个组件。姑且称之为小部件吧。小部件组件可以处理数据,在加载数据时设置加载器,并处理使用相同数据或不同数据的其他潜在组件。图 5-1 显示了组件的高层图。

img/510438_1_En_5_Fig1_HTML.jpg

图 5-1

直方图小部件图

这种架构设计让我能够为接下来发生的事情做好准备。例如,假设我们想添加一个显示一段时间内价格的列表或另一个使用相同数据的图表。

带反冲的直方图

我们做一个带反冲的直方图。

HistogramWidget.tsx:自定义组件

根据 HistogramWidget 组件,我将创建三个文件。

  • Graph.tsx:组件

  • Graph.scss:风格

  • Graph.test.tsx : Jest 测试

CRA·MHL 有一个现成的库来帮助创建模板,它已经配置了有助于更快完成工作的组件。只需运行下面的npx命令,使用我创建的反冲模板生成图形文件..

$ npx generate-react-cli component HistogramWidget --type=recoil

您应该会得到以下输出:

Stylesheet "HistogramWidget.scss" was created successfully at src/components/Graph/HistogramWidget.scss
Test "HistogramWidget.test.tsx" was created successfully at src/components/Graph/HistogramWidget.test.tsx
Component "HistogramWidget.tsx" was created successfully at src/components/Graph/HistogramWidget.tsx

小部件代码将检索我们在反冲选择器中设置的数据,并呈现图表。

初始代码为我们提供了创建加载机制的框架。我们使用useRecoilValue提取数据,然后更新视图。

const HistogramWidget= () => {
  const results: useRecoilValue( getMethod )
  useEffect(() => {
    // TODO
  })
  return (

      {results ? (
        <>Loaded
      ) : (
        <>Loading
      )}

  )
}
export default HistogramWidget

现在我们插入将检索数据的方法,getHistoricalPriceData,以及我们接下来将创建的带有一些propsHistogram组件来对齐它。我们的HistogramWidget.tsx会是这样的样子。:

// src/widgets/HistogramWidget/HistogramWidget.tsx

import React, { useEffect } from 'react'
import './HistogramWidget.scss'
import { useRecoilValue } from 'recoil'
import { getHistoricalPriceData } from '../../recoil/selectors/historicalPriceSelectors'
import { historicalPriceObject } from '../../model'
import Histogram from '../../components/Histogram/Histogram'

const HistogramWidget = () => {

为了检索结果,我们useRecoilValue调用选择器并转换对象。

  const results: historicalPriceObject = useRecoilValue(getHistoricalPriceData) as historicalPriceObject

这段代码类似 React 的useState,非常直观。这就是后坐力发光的原因。

在渲染方面,我检查是否有。结果已经显示,或者显示直方图组件,或者显示“正在加载”的消息为此,我将使用jsx条件内联。看一看:

  return (
    <>
      {results?.length > 0 ? (
        <>
          <Histogram data={results} margin={{ top: 20, right: 45, bottom: 20, left: 50 }} width={400} height={400} fill="tomato" />

      ) : (
        <>Loading!
      )}

  </>
  )
}
export default HistogramWidget

数据绑定在Histogram组件上,由于我的两个对象Types.Data[]historicalPriceObject[]是相同的,TypeScript 不会抱怨。

这里我只是使用了一个加载消息,但这可以是任何组件、动画或图像。

直方图 scss

我不需要任何 SCSS 风格,所以只要保持HistogramWidget.scss。作为占位符。

.histogramWidget {
}

Graph.test.tsx

我们的 Jest 测试使用反冲有点不同。投保全险是个好习惯。

我保持我的测试简单,只是检查组件被安装。为此,我需要将我的反冲放在<RecoilRoot>标签中。

// src/component/HistogramWidget/HistogramWidget.test.tsx

import React from 'react'
import { shallow } from 'enzyme'
import { RecoilRoot } from 'recoil'
import Graph from './Graph'

describe('<HistogramWidget />', () => {
  let component

  beforeEach(() => {
    component = shallow(
      <RecoilRoot>
        <HistogramWidget />
      </RecoilRoot>
    )
  })

  test('It should mount', () => {
    expect(component.length).toBe(1)
  })
})

App.tsx

最后,一切准备就绪。我可以删除检索数据的useEffect代码,只放置我的小部件。

// src/App.tsx

import React from 'react'
import './App.scss'
import HistogramWidget from './components/HistogramWidget/HistogramWidget'

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <HistogramWidget />
      </header>
    </div>
  )
}

export default App

见图 5-2 。

img/510438_1_En_5_Fig2_HTML.jpg

图 5-2

带反冲的直方图

价格表列表组件

在本章的这一节中,我将向您展示我们现在如何能够包含另一个组件,它可以与我们的直方图共享相同的数据。

我们将创建一个表格列表,在图表旁边显示日期和价格。

组件将使用 Material-UI。我为您设置了一个入门组件,或者您可以从头开始。

$ npx generate-react-cli component PriceTableList --type=materialui

types.ts

对于数据类型,我正在为我的PriceTableList.tsx组件创建另一个types.ts

这可能看起来有点过了,因为现在我在每个组件中都有两个相同的类型。然而,对我来说重要的是,我能够在未来的项目中借用这些组件,编写几行代码是一个很小的代价。

// src/component/PriceTableList/types.ts

export namespace Types {
  export type Data = {
    price: number
  }
}

PriceTableList.tsx

PriceTableList.tsx组件将使用makeStyle为根容器和表格组件创建样式。

我们将使用 Material-UI 中的TableBodyTableCellTableContainerTableRowPaper组件,因此它们需要被导入。

为了更好地理解材质-用户界面表,请看一下材质-用户界面文档:

https://material-ui.com/components/tables/

代码如下:

// src/components/PriceTableList/PriceTableList.tsx

import React from 'react'
import { makeStyles } from '@material-ui/core/styles'
import Table from '@material-ui/core/Table'
import TableBody from '@material-ui/core/TableBody'
import TableCell from '@material-ui/core/TableCell'
import TableContainer from '@material-ui/core/TableContainer'
import TableHead from '@material-ui/core/TableHead'
import TableRow from '@material-ui/core/TableRow'
import Paper from '@material-ui/core/Paper'
import { Types } from './types'
import './PriceTableList.scss'

使用 Material-UI 中的makeStyles,您可以为每个组件设置一个样式。对我来说,我希望容器包装的最大高度为 400 像素,因为列表和表格很长。我也可以在这里设置样式。

const useStyles = makeStyles({
  root: {
    maxHeight: 400,
  },
  table: {
    minWidth: 650
  },
})

我的函数组件将包括IPriceTableListProps prop 接口,该接口将包括表格文本的数据和颜色。

const PriceTableList = (props: IPriceTableListProps) => {

我们设置const来使用样式。

  const classes = useStyles()

在渲染时,我使用我设置的 Material-UI 样式创建了TableContainer和表格。

  return (
    <TableContainer className={classes.root} component={Paper}>
      <Table className={classes.table} aria-label="simple table">
        <TableHead>
          <TableRow>

对于表头和行,我使用了一个自定义样式,我将在 SCSS 文件中创建该样式来设置背景以及从父组件传递过来的文本颜色。

            <TableCell className="priceTableListTableCellHead" style={{ color: props.textColor }}>
              Day
            </TableCell>
            <TableCell className="priceTableListTableCellHead" style={{ color: props.textColor }}>
              Price
            </TableCell>
          </TableRow>
        </TableHead>
        <TableBody>

为了遍历数据,我可以使用map方法传递价格值,并创建一个索引来设置天数。

          {props.data.map((d: Types.Data, index: number) => (
            <TableRow key={d.price}>
              <TableCell className="priceTableListTableCell" style={{ color: props.textColor }} component="th" scope="row">
                {index + 1}
              </TableCell>
              <TableCell className="priceTableListTableCell" style={{ color: props.textColor }} component="th" scope="row">

为了显示价格,我可以通过添加一个美元符号来格式化文本,并将变量转换为一个字符串以作为浮点数进行解析,并且我设置了一个固定值 2(只保留两位数)。

                ${parseFloat((d.price as unknown) as string).toFixed(2)}
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </TableContainer>
  )
}

export default PriceTableList

该接口保存数据类型和文本颜色。

interface IPriceTableListProps {
  data: Types.Data
  textColor: string
}

价格表列表

对于 SCSS 文件,我为标题行和实际行设置了两种不同的背景色。

.priceTableListTableCellHead {
  background-color: #343434;
}

.priceTableListTableCell {
  background-color: #515151;
}

就这样,我准备将PriceTableList.tsx组件集成到父组件HistogramWidget.tsx中。

HistogramWidget.tsx

HistogramWidget.tsx父组件的更改将是使用 Material-UI 网格并排设置我的两个组件,并添加我的PriceTableList.tsx组件。查看该文件(突出显示了更改):

// src/widgets/HistogramWidget/HistogramWidget.tsx

我需要导入网格组件和HistogramWidget.tsx

import React, { useEffect } from 'react'
import './HistogramWidget.scss'
import { useRecoilValue } from 'recoil'
import Grid from '@material-ui/core/Grid'
import { getHistoricalPriceData } from '../../recoil/selectors/historicalPriceSelectors'
import { historicalPriceObject } from '../../model'
import Histogram from '../../components/Histogram/Histogram'
import PriceTableList from '../../components/PriceTableList/PriceTableList'

const HistogramWidget = () => {
  const results: historicalPriceObject = useRecoilValue(getHistoricalPriceData) as historicalPriceObject
  return (

      {results?.length > 0 ? (

我的网格由两列组成。

          <Grid container spacing={5}>
            <Grid item xs={6}>
              <Histogram data={results} margin={{ top: 20, right: 45, bottom: 20, left: 50 }} width={400} height={400} fill="tomato" />
            </Grid>

对于价格表列表,我用一个div包装组件,以确保我们可以向下滚动,并且小部件可以控制组件的大小。

      <Grid item xs={6}>
              <div className="priceTableListDivWrapper">
                <PriceTableList data={results} textColor="white" />
              </div>
            </Grid>
          </Grid>

      ) : (
        Loading!
      )}

  )
}
export default HistogramWidget

直方图 scss

从小部件 SCSS,我需要为我的价格表列表的div包装添加样式。

.priceTableListDivWrapper {
  padding-top: 100px;
  width: 500px;
  height: 500px;
}

最后,和以前一样,记得运行formatlinttest命令以确保质量。

$ yarn format && yarn lint && yarn test

图 5-3 显示了最终结果。

img/510438_1_En_5_Fig3_HTML.jpg

图 5-3

具有列表材质 UI 组件和使用反冲的共享状态的直方图

查看我的 d3 和 React 交互课程,看看如何使用函数组件实现这个直方图,并使用钩子进行优化: https://elielrom.com/BuildSiteCourse

摘要

在这一章中,我谈到了由脸书引入的状态管理架构 Flux,并了解了来自脸书的名为反冲的新的实验性状态管理。

我们采用了上一章开发的直方图,并用反冲状态管理替换了 React 状态。使用反冲状态管理,我们能够跨应用和多个组件共享数据。

这个设计吸收了两个世界的精华,由 D3 的模块库和 React SPA 范例组成,前者帮助我们用数据可视化图表讲述故事,后者借助虚拟 DOM 确保页面只在发生变化时才呈现。

我们使用 Material-UI table list 组件来创建另一个组件并共享数据,我们将组件重组到一个小部件中,这样我们就可以轻松地集成加载共享数据的多个组件的逻辑。

现在您已经知道了如何使用 D3、图表和数据管理来创建定制的 React 组件。我鼓励你使用我给你的例子,插入数据,改变图表,并创建新的图表。这将帮助你获得宝贵的经验。

这本书的其余部分侧重于使用更复杂的图表以及优化和发布技术。

在下一章,我们将开始处理更复杂的图表。在接下来的两章中,内容将致力于创建和使用一种通用类型的图表,即世界地图。

六、世界地图:第一部分

世界地图是展示全球物品的好方法。将 D3 与 React 和 TS 集成在一起,可以创建使用所有工具中最好的可读代码。在这一章,我将向你展示如何创建一个旋转地图,并根据坐标分配点。

具体来说,在这一章中,我将向你展示如何使用 React、D3 和 TS 作为类型检查器来操作世界地图。我将把这个过程分成几个步骤。在每一步中,我将添加更多的功能,直到我们有了用点代表坐标的旋转世界地图。

我已经将组件分成了五个文件,所以很容易看到和比较变化。

  • 世界地图图册 : WorldMapAtlas.tsx

  • 圆形世界地图 : RoundWorldMap.tsx

  • 旋转圆形世界地图 : RotatingRoundWorldMap.tsx

  • 坐标为 : RotatingRoundWorldMapWithCoordinates.tsx的旋转圆形世界地图

  • 重构 : WorldMap.tsx

该项目可以从这里下载:

https://github.com/Apress/integrating-d3.js-with-react/tree/main/ch06/world-map-chart

设置

项目设置很简单,使用 CRA 与 MHL 模板项目。

$ yarn create react-app world-map-chart --template must-have-libraries
$ cd world-map-chart
$ yarn start
$ open http://localhost:3000

安装其他所需的库和类型

我们需要另外四个库来开始。

  • d3-geo:我们将使用d3-geo进行地理投影(绘制地图)。 https://github.com/d3/d3-geo

  • topojson-client:这是一个操纵 TopoJSON 的客户端。TopoJSON 是提供世界地图的库,我可以用它来绘制地图。https://github.com/topojson/topojson-client见。

  • geojson:这是地理数据的编码格式。 https://geojson.org/。TopoJSON 文件属于“拓扑”类型,遵循 TopoJSON 规范。GeoJSON 将用于格式化地理数据结构的编码。 https://geojson.org/

  • react-uuid:创建一个随机 UUID,我们将在映射 React 组件时使用它作为所需的列表键。https://github.com/uuidjs/uuid见。

继续用 Yarn 安装这些库:

$yarn add d3-geo @types/d3-geo
$yarn add topojson-client @types/topojson-client
$yarn add geojson @types/geojson
$yarn add react-uuid

最后,下载世界地图集的数据。数据由 TopoJSON 提供,其中包含预构建的状态数据( https://github.com/topojson/world-atlas )。下面是我将使用的实际 JSON:

https://d3js.org/world-110m.v1.json

将文件放在公共文件夹中以便于访问:/public/data/world-110m.json

世界地图图册

我将创建的第一个地图只是一个平面世界地图集类型的地图,将显示世界。

世界地图图册. tsx

自己创建文件或使用generate-react-cli

$ npx generate-react-cli component WorldMap --type=d3

正如我提到的,我将把组件作为单独的组件来创建,因此跟踪工作和比较变化将会很容易。第一档是WorldMapAtlas.tsx。以下是完整的组件代码:

// src/components/WorldMap/WorldMapAtlas.tsx
import React, { useState, useEffect } from 'react'
import { geoEqualEarth, geoPath } from 'd3-geo'
import { feature } from 'topojson-client'
import { Feature, FeatureCollection, Geometry } from 'geojson'
import './WorldMap.scss'

const uuid = require('react-uuid')

const scale: number = 200
const cx: number = 400
const cy: number = 150

const WorldMapAtlas = () => {
  const [geographies, setGeographies] = useState<[] | Array<Feature<Geometry | null>>>([])

  useEffect(() => {
    fetch('/data/world-110m.json').then((response) => {
      if (response.status !== 200) {
        // eslint-disable-next-line no-console
        console.log(`Houston we have a problem: ${response.status}`)
        return
      }
      response.json().then((worldData) => {
        const mapFeatures: Array<Feature<Geometry | null>> = ((feature(worldData, worldData.objects.countries) as unknown) as FeatureCollection).features
        setGeographies(mapFeatures)
      })
    })
  }, [])

  const projection

= geoEqualEarth().scale(scale).translate([cx, cy]).rotate([0, 0])

  return (
    <>
      <svg width={scale * 3} height={scale * 3} viewBox="0 0 800 450">
        <g>
          {(geographies as []).map((d, i) => (
            <path
              key={`path-${uuid()}`}
              d={geoPath().projection(projection)(d) as string}
              fill={`rgba(38,50,56,${(1 / (geographies ? geographies.length : 0)) * i})`}
              stroke="aliceblue"
              strokeWidth={0.5}
            />
          ))}
        </g>
      </svg>

  )
}

export default WorldMapAtlas

我们来复习一下。

第一步,我们导入 React 和我们安装的库。我还创建了WorldMap.scss作为未来使用的样式占位符。

import React, { useState, useEffect } from 'react'
import { geoEqualEarth, geoPath } from 'd3-geo'
import { feature } from 'topojson-client'
import { Feature, FeatureCollection, Geometry } from 'geojson'
import './WorldMap.scss'

对于react-uuid库,TS 没有类型,所以我将使用require,这样 ESLint 就不会抱怨了。

const uuid = require('react-uuid')

接下来,我们设置地图比例和定位等属性。

const scale: number = 200
const cx: number = 400
const cy: number = 150

WorldMapAtlas设置为功能组件。这是一个偏好问题,我可以使用类组件。

至于状态的数据,我将客户端数据设置为州。一旦加载了数据,我就将 JSON 转换成可以呈现的特征几何数组。

  const [geographies, setGeographies] = useState<[] | Array<Feature<Geometry | null>>>([])

就类型而言,我必须通过钻取实际的geojson库来确定类型。

接下来,我将数据加载到useEffect钩子上。在本章的后面,我将重构这段代码,并把它移到父组件中,但现在我希望代码尽可能简单。这是我的工作地图:

  useEffect(() => {
    fetch('/data/world-110m.json').then((response) => {
      if (response.status !== 200) {
        console.log(`Houston we have a problem: ${response.status}`)
        return
      }
      response.json().then((worldData) => {

注意,我使用的是“fetch ”,然而,另一种方法是使用 d3.json 模块。D3 已经将对象格式化为 JSON,所以代码更少。

  useEffect(() => {
    d3.json('/data/world-110m.json').then((d) => { return d }).then((worldData) => {
        // @ts-ignore const mapFeature: Array<Feature<Geometry | null>> = (feature(worldData, worldData.objects.countries) as FeatureCollection).features setGeographies(mapFeature)
    })
  })

一旦得到响应,我就可以将 JSON 转换成一个Geometry特性数组,并将其设置为函数状态。

        const mapFeatures: Array<Feature<Geometry | null>> = ((feature(worldData, worldData.objects.countries) as unknown) as FeatureCollection).features
        setGeographies(mapFeatures)
      })
    })
  }, [])

用外行人的话来说,这个投影就是我希望我的实际地图集的样子。有很多选项可以选择(见 https://github.com/d3/d3-geo/blob/master/README.md )。让我们以geoEqualEarth作为第一次尝试。

  const projection = geoEqualEarth().scale(scale).translate([cx, cy]).rotate([0, 0])

为了呈现我的地图集,我将首先设置一个 SVG 包装器,它保存一个组元素,然后使用一个 map 遍历路径,通过我设置为 state 的geographies数据来绘制每个状态。

  return (
    <>
      <svg width={scale * 3} height={scale * 3} viewBox="0 0 800 450">
        <g>
          {(geographies as []).map((d, i) => (
            <path
              key={`path-${uuid()}`}
              d={geoPath().projection(projection)(d) as string}
              fill={`rgba(38,50,56,${(1 / (geographies ? geographies.length : 0)) * i})`}
              stroke="aliceblue"
              strokeWidth={0.5}
            />
          ))}
        </g>
      </svg>
      </>

  )
}

export default WorldMapAtlas

注意,我正在使用key={path-${uuid()}}

键是标识唯一虚拟 DOM (VDOM) UI 元素及其相应数据的常见做法。如果不执行这一步,React VDOM 会在需要刷新 DOM 时感到困惑。这是最佳实践。您可以使用随机数,但请注意不要使用地图索引作为关键字,因为地图可能会发生变化,从而导致 VDOM 引用错误的项目。

按键帮助 React 识别哪些项目已经更改、添加或删除。应该给数组内部的元素赋予键,以给元素一个稳定的标识。

https://reactjs.org/docs/lists-and-keys.html

键和引用作为属性添加到一个React.createElement()调用中。它们通过回收 DOM 中的所有现有元素来帮助 React 优化渲染。

App.tsx

接下来,让我们将我们的WorldMapAtlas组件作为子组件添加到App.tsx中。

请注意,更改以粗体突出显示。

import React from 'react'
import './App.scss'
import WorldMapAtlas from './components/WorldMap/WorldMapAtlas'

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <WorldMapAtlas />
      </header>
    </div>
  )
}

export default App

app . scss

对于App.scss样式,我将背景颜色改为白色。

.App-header {
  background-color: #ffffff;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: calc(10px + 2vmin);
  color: white;
}

就是!参见图 6-1 。

img/510438_1_En_6_Fig1_HTML.jpg

图 6-1

简单世界地图图册

正如我提到的,对于投影,我使用了geoEqualEarth;但是,我可以很容易地将投影更改为其他投影。例如,如果我想换到geoStereographic,我的地图就会改变。见图 6-2 。

从此处更改投影:

import { geoEqualEarth, geoPath} from 'd3-geo'

const projection = geoEqualEarth().scale(scale).translate([cx, cy]).rotate([0, 0])

致以下内容:

img/510438_1_En_6_Fig2_HTML.jpg

图 6-2

使用地球立体投影的世界地图集

import { geoPath, geoStereographic } from 'd3-geo'
const projection = geoStereographic().scale(scale).translate([cx, cy]).rotate([0, 0])

另一个例子是geoConicConformal投影(图 6-3 )。

img/510438_1_En_6_Fig3_HTML.jpg

图 6-3

使用地理共形投影的世界地图集

const projection = geoConicConformal().scale(scale).translate([cx, cy]).rotate([0, 0])

圆形世界地图

现在我们知道如何绘制世界地图图册。接下来我们将看到如何创建一个圆形的世界地图。

要改变地图为圆形,我所要做的就是使用geoOrthographic投影。

为了让圆形地图看起来更好,我还打算使用 SVG circle 元素绘制一个圆形浅灰色背景。

RoundWorldMap.tsx

创建一个名为RoundWorldMap.tsx的新组件;查看此处突出显示的更改。

// src/components/WorldMap/RoundWorldMap.tsx

  const projection = geoOrthographic().scale(scale).translate([cx, cy]).rotate([rotation, 0])

  return (
    <svg width={scale * 3} height={scale * 3} viewBox="0 0 800 450">
      <g>
        <circle
          fill="#0098c8"
          cx={cx}
          cy={cy}
          r={scale}
        />
      </g>
      <g>
        {(geographies as []).map((d, i) => (
          <path
            key={`path-${uuid()}`}
            d={geoPath().projection(projection)(d) as string}
            fill={`rgba(38,50,56,${(1 / (geographies ? geographies.length : 0)) * i})`}
            stroke="aliceblue"
            strokeWidth={0.5}
          />
        ))}
      </g>
    </svg>
  )
}

记得更新App.tsx

return (
  <div className="App">
    <header className="App-header">
      <RoundWorldMap />
    </header>
  </div>
)

见图 6-4 。

img/510438_1_En_6_Fig4_HTML.jpg

图 6-4

环球地图图册

旋转圆形世界地图

现在我们有了一个圆形的世界地图图集,再加上动画和互动岂不是很棒?我们可以旋转图集,添加一个按钮开始动画。

AnimationFrame.tsx

要添加动画,我们可以调用 JavaScript 窗口requestAnimationFrame API ( https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame )。

requestAnimationFrame方法告诉浏览器我要执行一个动画,浏览器会调用我的回调函数,这样我就可以在下次重绘之前更新我的动画。

要使用requestAnimationFrame,我可以将下面的代码放在我的 React 组件中:

window.requestAnimationFrame(() => {
  // TODO
})

但是,更好的架构设计是使用useRef创建一个 hook 函数组件,并包装我的requestAnimationFrame。看一看:

// src/hooks/WindowDimensions.tsx
import { useEffect, useRef } from 'react'

export default (callback: (arg0: ICallback) => void) => {
  const frame = useRef()
  const last = useRef(performance.now())
  const init = useRef(performance.now())

  const animate = () => {
    const now = performance.now()
    const time = (now - init.current) / 1000
    const delta = (now - last.current) / 1000
    callback({ time, delta })
    last.current = now
    ;((frame as unknown) as IFrame).current = requestAnimationFrame(animate)
  }

  useEffect(() => {
    ((frame as unknown) as IFrame).current = requestAnimationFrame(animate)
    return () => cancelAnimationFrame(((frame as unknown) as IFrame).current)
  })
}

interface ICallback {
  time: number
  delta: number
}

让我们回顾一下代码。

我将回调作为参数传递。

export default (callback: (arg0: ICallback) => void) => {

接下来,我将使用performance.now() ( https://developer.mozilla.org/en-US/docs/Web/API/Performance/now )来跟踪帧。这个特性带来了一个一毫秒分辨率的时间戳,我可以用它来计算时间增量,以备不时之需。

Note

时间差是时间上的差异。

  const frame = useRef()
  const last = useRef(performance.now())
  const init = useRef(performance.now())

在每次调用requestAnimationFrame时,animate将返回当前时间戳。

  const animate = () => {
    const now = performance.now()
    const time = (now - init.current) / 1000
    const delta = (now - last.current) / 1000
    callback({ time, delta })
    last.current = now;
    (frame as unknown as IFrame).current = requestAnimationFrame(animate)
  }

然后我可以使用useEffect钩子来绑定animate方法。

我的效果需要在组件离开屏幕之前清理干净。为此,传递给useEffect的函数需要返回一个清理函数。在我的例子中,cancelAnimationFrame需要在useEffect返回回调中被调用。你可以在这里了解更多关于 React 特效和清理: https://reactjs.org/docs/hooks-reference.html

  useEffect(() => {
    (frame as unknown as IFrame).current = requestAnimationFrame(animate)
    return () => cancelAnimationFrame((frame as unknown as IFrame).current)
  })
}

RotatingRoundWorldMap.tsx

现在我们已经准备好将AnimationFrame钩子用于我们的动画,我可以添加我的旋转动画了。此外,我将从 Material-UI 添加一个图标按钮形式的用户手势来启动动画。

从我们之前的例子中复制RoundWorldMap.tsx文件,并将其保存为一个名为RotatingRoundWorldMap.tsx的新文件。看看这些来自RoundWorldMap.tsx的变化:

// src/components/WorldMap/RotatingRoundWorldMap.tsx

import PlayCircleFilledWhiteIcon from '@material-ui/icons/PlayCircleFilledWhite'
import { Button } from '@material-ui/core'

动画逻辑检查 360 度旋转是否结束,以在每次完成 360 度时重置旋转变量。动画检查isRotate状态是否设置为真,这样我的地图只有在我点击开始按钮时才会开始旋转。

  AnimationFrame(() => {
    if (isRotate) {
      let newRotation = rotation
      if (rotation >= 360) {
        newRotation = rotation - 360
      }
      setRotation(newRotation + 0.2)
      // console.log(`rotation: ${  rotation}`)
    }
  })

我正在添加一个按钮来启动动画。这是通过使用粗箭头内嵌函数将isRotate状态设置为 true 来实现的。

  return (
    <>
      <Button
        size="medium"
        color="primary"
        startIcon={<PlayCircleFilledWhiteIcon />}
        onClick={() => {
          setIsRotate(true)
        }}
      />
      <svg width={scale * 3} height={scale * 3} viewBox="0 0 800 450">
        <g>
          <circle
            fill="#0098c8"
            cx={cx}
            cy={cy}
            r={scale}
          />
        </g>
        <g>
          {(geographies as []).map((d, i) => (
            <path
              key={`path-${uuid()}`}
              d={geoPath().projection(projection)(d) as string}
              fill={`rgba(38,50,56,${(1 / (geographies ? geographies.length : 0)) * i})`}
              stroke="aliceblue"
              strokeWidth={0.5}
            />
          ))}
        </g>
      </svg>
      </>

  )
}

记得更新App.tsx以包含RotatingRoundWorldMap组件。

return (
  <div className="App">
    <header className="App-header">
      <RotatingRoundWorldMap />
    </header>
  </div>
)

图 6-5 显示了最终结果。

img/510438_1_En_6_Fig5_HTML.jpg

图 6-5

旋转圆形世界地图图册

我们需要做的另一个改变是将地图数据的加载放在一个检查数据是否被加载的语句中。

原因是由于使用了动画钩子,useEffect 会一直被调用。

看一看;

useEffect(() => { if (geographies.length === 0) {
    // load map
}

带坐标的旋转圆形世界地图

在这一节中,我将向你展示如何在我们的地图上添加坐标点。

rotationroundworldmapwithcoordinates . tsx

复制前面例子中的RotatingRoundWorldMap.tsx文件,并将其命名为RotatingRoundWorldMapWIthCoordinates.tsx

是的,我知道这是一个很长的名字,但是使用莎士比亚的方法名,很容易看出这个组件在做什么。

为了创建坐标点,我将添加一个新的数据数组提要,其中包括坐标的经度和纬度。看看与之前的RotatingRoundWorldMap.tsx组件相比的变化:

// src/components/WorldMap/RotatingRoundWorldMapWIthCoordinates.tsx
import React, { useState, useEffect } from 'react'
import { geoOrthographic, geoPath } from 'd3-geo'
import { feature } from 'topojson-client'
import { Feature, FeatureCollection, Geometry } from 'geojson'
import './WorldMap.scss'
import PlayCircleFilledWhiteIcon from '@material-ui/icons/PlayCircleFilledWhite'
import { Button } from '@material-ui/core'
import AnimationFrame from '../../hooks/AnimationFrame'

const uuid = require('react-uuid')

const data: { name: string; coordinates: [number, number] }[] = [
  { name: '1', coordinates: [-73.9919, 40.7529] },
  { name: '2', coordinates: [-70.0007884457405, 40.75509010847814] },
]

const scale: number = 200
const cx: number = 400
const cy: number = 150
const initRotation: number = 50

const RotatingRoundWorldMapWithCoordinates = () => {
  const [geographies, setGeographies] = useState<[] | Array<Feature<Geometry | null>>>([])
  const [rotation, setRotation] = useState<number>(initRotation)
  const [isRotate, setIsRotate] = useState<Boolean>(false)

  useEffect(() => {
    if (geographies.length === 0) {
        fetch('/data/world-110m.json').then((response) => {
          if (response.status !== 200) {
            // eslint-disable-next-line no-console
            console.log(`Houston we have a problem: ${response.status}`)
          return
        }
        response.json().then((worldData) => {
          const mapFeatures: Array<Feature<Geometry | null>> = ((feature(worldData, worldData.objects.countries) as unknown) as FeatureCollection).features
          setGeographies(mapFeatures)
        })
      })
    }
  }, [])

  // geoEqualEarth
  // geoOrthographic
  const projection = geoOrthographic().scale(scale).translate([cx, cy]).rotate([rotation, 0])

  AnimationFrame(() => {
    if (isRotate) {
      let newRotation = rotation
      if (rotation >= 360) {
        newRotation = rotation - 360
      }
      setRotation(newRotation + 0.2)
      // console.log(`rotation: ${  rotation}`)
    }
  })

  function returnProjectionValueWhenValid(point: [number, number], index: number) {
    const retVal: [number, number] | null = projection(point)
    if (retVal?.length) {
      return retVal[index]
    }
    return 0
  }

  const handleMarkerClick = (i: number) => {
    // eslint-disable-next-line no-alert

    alert(`Marker: ${JSON.stringify(data[i])}`)
  }

  return (
  <>

      <Button
        size="medium"
        color="primary"
        startIcon={<PlayCircleFilledWhiteIcon />}
        onClick={() => {
          setIsRotate(true)
        }}
      />
      <svg width={scale * 3} height={scale * 3} viewBox="0 0 800 450">
        <g>
          <circle fill="#f2f2f2" cx={cx} cy={cy} r={scale} />
        </g>
        <g>
          {(geographies as []).map((d, i) => (
            <path
              key={`path-${uuid()}`}
              d={geoPath().projection(projection)(d) as string}
              fill={`rgba(38,50,56,${(1 / (geographies ? geographies.length : 0)) * i})`}
              stroke="aliceblue"
              strokeWidth={0.5}
            />
          ))}
        </g>
        <g>
          {data.map((d, i) => (
            <circle

              key={`marker-${uuid()}`}
              cx={returnProjectionValueWhenValid(d.coordinates, 0)}
              cy={returnProjectionValueWhenValid(d.coordinates, 1)}
              r={5}
              fill="#E91E63"
              stroke="#FFFFFF"
              onClick={() => handleMarkerClick(i)}
              onMouseEnter={() => setIsRotate(false)}
            />
          ))}
        </g>
      </svg>

  </>
  )
}
export default RotatingRoundWorldMapWithCoordinates

Let’s review the changes in RotatingRoundWorldMapWIthCoordinates from RoundWorldMapAtlas. I am setting a data object that includes the names and coordinates.
const data: { name: string; coordinates: [number, number] }[] = [
  { name: '1', coordinates: [-73.9919, 40.7529] },
  { name: '2', coordinates: [-70.0007884457405, 40.75509010847814] },
]

对于初始的世界地图旋转位置,我可以用一个常量来设置,我希望世界地图开始旋转的角度。

const initRotation: number = 50

接下来,我将添加一个returnProjectionValueWhenValid方法来调整点的位置。这是需要的,因为世界地图是动画,投影地图上的位置将会改变。

function returnProjectionValueWhenValid(point: [number, number], index: number) {
    const retVal: [number, number] | null = projection(point)
    if (retVal?.length) {
      return retVal[index]
    }
    return 0
  }

一旦用户点击圆点,我将设置一个处理程序。一旦用户被点击,这可以用来打开一个详细的信息窗口或你想做的任何事情。

  const handleMarkerClick = (i: number) => {
    alert(`Marker: ${  JSON.stringify( data[i])}` )
  }

在渲染时,我将使用数组映射属性遍历坐标数组,并设置onClick处理程序和鼠标输入事件来停止动画,这样更容易单击标记。

        <g>
          {data.map((d, i) => (
            <circle
              key={`marker-${uuid()}`}
              cx={returnProjectionValueWhenValid(d.coordinates, 0)}
              cy={returnProjectionValueWhenValid(d.coordinates, 1)}
              r={5}
              fill="#E91E63"
              stroke="#FFFFFF"
              onClick={() => handleMarkerClick(i)}
              onMouseEnter={() => setIsRotate(false)}
            />
          ))}
        </g>

请注意,就像上一步一样,每次我在 React 中使用地图时,我都会为每个项目添加一个唯一的键。

最后,记得更新App.tsx

return (
  <div className="App">
    <header className="App-header">
<RotatingRoundRotatingRoundWorldMapWithCoordinatesWIthCoordinates />
    </header>
  </div>
)

图 6-6 显示了最终结果。

img/510438_1_En_6_Fig6_HTML.jpg

图 6-6

用坐标旋转圆形世界地图图册

重构

在本章的最后一步,我将做一些简单的重构工作。我将做以下事情:

  • 属性:提取props属性,以便父组件调整属性和数据。

  • 坐标:将坐标数据设置为 CSV 格式的第二个数据进给。

  • Loader :从父组件加载数据,使用异步任务将数据传递给 map chart。

  • Types :添加 TypeScript 类型,使代码可读性更好,避免错误,并有助于测试。

坐标. csv

最好将坐标数据提取到一个单独的 CSV 数据文件中。那不仅仅是为了清理代码。数据可能会增长,我可能需要从外部来源获取这些数据。我把坐标分解成纬度和经度,以防我需要用到这些信息。

id,latitude,longitude
1,-73.9919,40.7529
2,-70.0007884457405,40.75509010847814

将文件放在这里以便于访问:world-map-chart/public/data/coordinates.csv

types.tsx

正如您将在本书中看到的,TS 中的一个常见做法是为 TypeScript 创建类型。这不仅是一个好的实践,而且会清理代码,使其更具可读性。在我的例子中,我将为两个数据馈送设置两种类型:CoordinatesDataMapObject

// src/component/BasicScatterChart/types.ts

import { Feature, Geometry } from 'geojson'

export namespace Types {
  export type CoordinatesData = {
    id: number
    latitude: number
    longitude: number
  }

  export type MapObject = {
    mapFeatures: Array<Feature<Geometry | null>>
  }
}

引用类型时,通常使用小写的首字母。我们正在创建引用,因此任何一种方式都可以。换句话说,你可以决定你喜欢什么,但要保持一致。我想让你知道这两种选择。

export type coordinatesData

请注意,如果您以小写字母开头,您将会得到一个 lint 错误,因为我们的 lint 规则被设置为对任何不是以大写字母开头的导出类型进行投诉。您可以禁用它,只是这次禁用或全局禁用。

// eslint-disable-next-line @typescript-eslint/naming-convention

export type coordinatesData

WorldMap.tsx

对于我们的重构工作,复制前面示例中的RotatingRoundWorldMapWIthCoordinates.tsx文件,并将其命名为WorldMap.tsx

大部分代码保持不变。

对于props接口,我将添加数据馈送和对齐属性,因此我需要添加props接口并更改函数签名。

WorldMap.tsx的大部分改变只是增加了这些props而不是数据。更改以粗体突出显示。

// src/components/WorldMap/WorldMap.tsx

import React, { useState } from 'react'
import { geoOrthographic, geoPath } from 'd3-geo'
import './WorldMap.scss'
import PlayCircleFilledWhiteIcon from '@material-ui/icons/PlayCircleFilledWhite'
import { Button } from '@material-ui/core'
import AnimationFrame from '../../hooks/AnimationFrame'
import { Types } from './types'

const uuid = require('react-uuid')

const WorldMap = (props: IWorldMapProps) => {
  const [rotation, setRotation] = useState<number>(props.initRotation)
  const [isRotate, setIsRotate] = useState<Boolean>(false)

  const projection = geoOrthographic().scale(props.scale).translate([props.cx, props.cy]).rotate([rotation, 0])

  AnimationFrame(() => {
    if (isRotate) {
      let newRotation = rotation
      if (rotation >= 360) {
        newRotation = rotation - 360
      }
      setRotation(newRotation + 0.2)
    }
  })

  function returnProjectionValueWhenValid(point: [number, number], index: number ) {
    const retVal: [number, number] | null = projection(point)
    if (retVal?.length) {
      return retVal[index]
    }
    return 0
  }

  const handleMarkerClick = (i: number) => {
    alert(`Marker: ${  JSON.stringify( props.coordinatesData[i].id)}` )
  }

  return (
  <>

      <Button
        size="medium"
        color="primary"
        startIcon={<PlayCircleFilledWhiteIcon />}
        onClick={() => {
          setIsRotate(true)
        }}
      />
      <svg width={props.scale * 3} height={props.scale * 3} viewBox="0 0 800 450">
        <g>
          <circle
            fill="#f2f2f2"
            cx={props.cx}
            cy={props.cy}
            r={props.scale}
          />
        </g>
        <g>
          {(props.mapData.mapFeatures as ).map((d, i) => (
            <path
              key={`path-${uuid()}`}
              d={geoPath().projection(projection)(d) as string}
              fill={`rgba(30,50,50,${(1 / (props.mapData.mapFeatures ? props.mapData.mapFeatures.length : 0)) * i})`}
              stroke="aliceblue"
              strokeWidth={props.rotationSpeed}
            />
          ))}
        </g>
        <g>
          {props.coordinatesData?.map((d, i) => (
            <circle
              key={`marker-${uuid()}`}
              cx={returnProjectionValueWhenValid([d.latitude, d.longitude], 0)}
              cy={returnProjectionValueWhenValid([d.latitude, d.longitude], 1)}
              r={5}
              fill="#E91E63"
              stroke="#FFFFFF"
              onClick={() => handleMarkerClick(i)}
              onMouseEnter={() => setIsRotate(false)}
            />
          ))}
        </g>
      </svg>

  </>
  )
}

export default WorldMapinterface IWorldMapProps {
  mapData: Types.MapObject
  coordinatesData: Types.CoordinatesData[]
  scale: number
  cx: number
  cy: number
  initRotation: number
  rotationSpeed: number
}

如您所见,与上一个示例中的RotatingRoundWorldMapWIthCoordinates.tsx文件相比,我们在WorldMap.tsx中的代码现在更加清晰,可读性更好。

App.tsx

对于父组件,我将提取数据,把它放在 effect 钩子中,并使用 D3 加载 JSON 和 CSV。

因为我想在绘制地图之前加载两个数据集,所以一个好的方法是在将数据传递给图表组件之前对两个数据集使用异步调用。

我们可以用 D3 的queue()https://github.com/d3/d3-queue。D3 的queue()做异步任务。添加这些 D3 模块和 TS 类型。

第一步是将这些库添加到我们的项目中;

$ yarn add d3-queue d3-request @types/d3-queue @types/d3-request

接下来,让我们重构我们的App.tsx

// src/App.tsx

import React, { useEffect, useState } from 'react'
import './App.scss'
import { queue } from 'd3-queue'
import { csv, json } from 'd3-request'
import { FeatureCollection } from 'geojson'
import { feature } from 'topojson-client'
import WorldMap from './components/WorldMap/WorldMap'
import { Types } from './components/WorldMap/types'

function App() {
  const [mapData, setMapData] = useState<Types.MapObject>({ mapFeatures: [] })
  const [coordinatesData, setCoordinatesData] = useState<Types.CoordinatesData[]>([])

  useEffect(() => {
    if (coordinatesData.length === 0) {
      const fileNames = ['./data/world-110m.json', './data/coordinates.csv']
      queue()
        .defer(json, fileNames[0])
        .defer(csv, fileNames[1])
        .await((error, d1, d2: Types.CoordinatesData[]) => {
          if (error) {
            // eslint-disable-next-line no-console
            console.log(`Houston we have a problem:${error}`)
          }
          setMapData({ mapFeatures: ((feature(d1, d1.objects.countries) as unknown) as FeatureCollection).features })
          setCoordinatesData(d2)
        })
    }
  })
  return (
    <div className="App">
      <header className="App-header">
        <WorldMap mapData={mapData} coordinatesData={coordinatesData} scale={200} cx={400} cy={150} initRotation={50} rotationSpeed={0.5} />
      </header>
    </div>
  )
}

export default App

让我们回顾一下代码。

我们为geojsontopojson-client添加了imports,因为我们将在这里上传数据。我还使用d3-request来加载数据,而不是之前使用的 fetch。

import { queue } from 'd3-queue'
import { csv, json } from 'd3-request'
import { FeatureCollection } from 'geojson'
import { feature } from 'topojson-client'
import WorldMap from './components/WorldMap/WorldMap'
import { Types } from './components/WorldMap/types'

我正在将数据馈送设置为状态;这将允许我分配props并确保 React 在数据加载后刷新props

  const [mapData, setMapData] = useState<Types.MapObject>({ 'mapFeatures': [] })
  const [coordinatesData, setCoordinatesData] = useState<Types.CoordinatesData[]>([])

吊钩将承担起重物。if语句确保我不会多次加载我的数据。

D3 queue将加载两个数据馈送并设置状态。

  useEffect(() => {
    if ( coordinatesData.length === 0 ) {
      const fileNames = ['./data/world-110m.json', './data/coordinates.csv']
      queue()
        .defer(json, fileNames[0])
        .defer(csv, fileNames[1])
        .await((error, d1, d2: Types.CoordinatesData[]) => {
          if (error) {
            console.log(`Houston we have a problem:${  error}`)
          }
          setMapData({ mapFeatures: ((feature(d1, d1.objects.countries) as unknown) as FeatureCollection).features })
          setCoordinatesData(d2)
        })
    }
  })

最后,我需要用数据和属性props设置WorldMap,以匹配我们为WorldMap组件设置的props接口。

        <WorldMap mapData={mapData} coordinatesData={coordinatesData} scale={200} cx={400} cy={150} initRotation={50} rotationSpeed={0.5} />

从用户的角度来看,一旦检查了端口 3000: http://localhost:3000,实际上没有什么变化。然而,我的代码更有组织性,更容易阅读,也更容易实现状态管理,如反冲或 Redux,因为数据是从实际组件中提取的,可以与多个组件共享。

摘要

在本章中,我们在 D3、TopoJSON 和 React 的帮助下创建了一个世界地图。将地图绘制为背景并添加点、动画和交互的能力有助于创建引人注目的图表,该图表可用于讲述您的故事。

在这一章中,我将步骤分解为五个部分,并创建了五个组件。

  • 世界地图图册 : WorldMapAtlas.tsx

  • 圆形世界地图 : RoundWorldMap.tsx

  • 旋转圆形世界地图 : RotatingRoundWorldMap.tsx

  • 坐标为 : RotatingRoundWorldMapWithCoordinates.tsx的旋转圆形世界地图

  • 重构 : WorldMap.tsx

从这一章可以看出,在topojsongeojson的帮助下使用 D3 集成世界地图图集是很简单的。

让 React 参与进来使得添加动画和交互更加直观。TS 有助于确保我们理解我们正在做的事情,并避免潜在的错误,在做了一些重构后,您可以看到我们的组件不仅是可重用的,而且可以进行状态管理。

在下一章中,我将向您展示如何使用我们在这里创建的地图并实现反冲状态管理和一个列表来创建一个以交互方式显示简历的小部件。

七、世界地图:第二部分

在前一章中,我们使用 React 和 D3 创建了一个世界地图图集。在本章中,我将向您展示如何使用我们创建的 map 组件来跨多个组件共享状态并与 map 进行交互。

潜在客户经常问我以前在不同公司的工作和角色,这样他们就能知道我是否适合他们当前的项目。我将使用地图创建一个交互式简历,显示我以前的客户及其在世界各地的位置。

图 7-1 在我的网站上显示最终结果: https://elielrom.com/about

img/510438_1_En_7_Fig1_HTML.jpg

图 7-1

互动简历的最终结果

这一章是上一章的延续。

就结构而言,这一章分为三个步骤。

  • 第一步:设置

  • 第二步:状态管理

  • 步骤 3 :小工具创建

我们开始吧。

设置

为了保持一切整洁,我将开始一个新项目。项目设置是快速和简单的使用 CRA 与 MHL 模板项目,你应该熟悉了。

$ yarn create react-app world-map-widget --template must-have-libraries
$ cd world-map-widget
$ yarn start
$ open http://localhost:3000

正如我们在前一章所做的,我们需要安装额外的库和 TS 类型。

$ yarn add d3 @types/d3
$ yarn add d3-geo @types/d3-geo topojson-client @types/topojson-client
$ yarn add @types/geojson geojson
$ yarn add react-uuid

在前一章中,我使用了coordinates.csv,它包括idlatitudelongitude

代替coordinates.csv,对于客户端列表,我将创建一个新的 CSV 文件,该文件将包括客户端的 CSV 格式的数据馈送,然后添加其他字段。

用以下字段创建/public/data/client-list.csv:

id,latitude,longitude,name,logo,description,address,city,state,country,website

对于地图数据,从上一个项目中复制相同的world-110m.json文件并放在这里:/public/data/world-110m.json

共享状态管理

你可能还记得,在第五章中,我向你展示了如何处理反冲和共享状态。在这一章中,我们将做同样的事情,并通过跨不同组件共享数据来扩展这个主题。

我将创建一个模型对象来保存我们的类型,然后创建反冲选择器。

模型文件

有两种数据馈送,分别用于地图和客户端列表。

  • clientsObject.ts

  • mapObject.ts

如果你看代码,你会看到我正在初始化对象。当我需要设置一些测试并需要缺省值时,这将在本章后面派上用场。

// src/model/clientsObject.ts

export interface clientsObject {
  id: number
  latitude: number
  longitude: number
  name: string
  logo: string
  description: string
  address: string
  city: string
  state: string
  country: string
  website: string
}

export const initClientsObject = (): clientsObject => ({
  id: -1,
  latitude: 0,
  longitude: 0,
  name: '',
  logo: '',
  description: '',
  address: '',
  city: '',
  state: '',
  country: '',
  website: '',
})

地图对象保存地图特征。这与我们在App.tsx的前一章中的代码相同。这一次,我将把代码从App.tsx中移出,放到它自己的小部件组件中。这个过程并不新鲜。我们在第五章中做了同样的事情。

我们的 map 对象将包括对象本身的方法以及初始化和设置保存状态数组的对象的方法。看一看:

// src/model/mapObject.ts

import { Feature, Geometry } from 'geojson'

export interface mapObject {
  mapFeatures: Array<Feature<Geometry | null>>
}

export const initMapObject = (): mapObject => ({
  mapFeatures: Array<Feature<null>>(),
})

export const setMapObject = (data: Array<Feature<Geometry | null>>): mapObject => ({
  mapFeatures: data,
})

原子

现在我们有了模型对象集,我们可以创建反冲的原子和选择器。反冲简化了状态管理,所以我们只需要创建两种成分:原子和选择器。

对于我们的例子,我们可以使用clientAtom.tsmapAtoms.ts来设置初始状态,但是我们不需要它们。我们创造的模型就足够了。

反冲选择器不需要使用来创建原子,能够跳过一个步骤并编写更少的代码是很好的。原子对于一些情况非常有用,比如当我们想要获得多个组件的状态更新或者将状态传递给选择器时。在我们的例子中,我们不需要这些功能,所以设置原子是多余的,可以跳过。

选择器

您可能还记得,选择器是允许您同步或异步转换状态的纯函数。

我们的选择器将为客户端列表和地图提取 CSV 数据。我将使用 D3 dsv API 在选择器异步调用中提取 CSV 格式。

// src/recoil/selectors/clientsSelectors.ts

import { selector } from 'recoil'
import * as d3 from 'd3'

import { clientsObject } from '../../model'

export const getPreviousClientListData = selector({
  key: 'GetPreviousClientListData',
  get: () => {
    return getData()
  },
})

const getData = async () =>
  new Promise((resolve) =>
    d3
      .dsv(',', '/data/client-list.csv', function results(d) {
        return d
      })
      .then(function results(data) {
        resolve((data as unknown) as clientsObject[])
      })
  )

对于地图,我将使用fetch内置命令,类似于我们在上一章中所做的,并从world-110m.json中提取数据。

// src/recoil/selectors/mapSelectors.ts

import { selector } from 'recoil'
import { Feature, FeatureCollection, Geometry } from 'geojson'
import { feature } from 'topojson-client'
import { setMapObject } from '../../model'

export const getMapData = selector({
  key: 'GetMapData',
  get: async () => {
    return getMapDataFromFile()
  },
})

const getMapDataFromFile = () =>
  new Promise((resolve) =>
    fetch('/data/world-110m.json').then((response) => {
      if (response.status !== 200) {
        console.log(`Houston, we have a problem! ${response.status}`)
        return
      }
      response.json().then((worldData) => {
        const mapFeatures: Array<Feature<Geometry | null>> = ((feature(worldData, worldData.objects.countries) as unknown) as FeatureCollection).features
        resolve(setMapObject(mapFeatures))
      })
    })
  )

如果你想创建一个 atom,你可以把我返回的对象转换成那个类型。这一步不是必需的,正如您将看到的那样,代码不这样做也能正常工作。

小部件

就我们的前端部件而言,你可以在图 7-2 中看到前端的线框。

img/510438_1_En_7_Fig2_HTML.jpg

图 7-2

ClientsWidget 组件和子组件高级线框

要生成这些组件、Jest 测试和 SCSS 文件,您可以再次使用我在 CRA/MHL 放置的模板或自己创建的模板generate-react-cli

$ npx generate-react-cli component ClientsWidget --type=recoil
$ npx generate-react-cli component WorldMap --type=d3
$ npx generate-react-cli component ClientList --type=recoil
$ npx generate-react-cli component ClientListDetail --type=recoil

每个生成三个文件:Component.tsxComponent.test.tsxComponent.scss。以ClientList输出为例,如图 7-3 所示。

img/510438_1_En_7_Fig3_HTML.jpg

图 7-3

CRA·MHL 模板生成的客户列表

WorldMap.tsx

接下来,我将使用我们在前一章中创建的WorldMap.tsx组件,并做一些额外的重构,使它符合我们的需求。

突出显示了这些更改。看一看:

// src/components/WorldMap/WorldMap.tsx
import React, { useState } from 'react'
import { geoEqualEarth, geoPath } from 'd3-geo'
import './WorldMap.scss'
import AnimationFrame from '../../hooks/AnimationFrame'
import { Types } from './types'
import PulsatingCircle from '../PulsatingCircle/PulsatingCircle'
import { clientsObject } from '../../model'

const uuid = require('react-uuid')

const WorldMap = (props: IWorldMapProps) => {
  const [rotation, setRotation] = useState<number>(props.initRotation)
  const [isRotate, setIsRotate] = useState<Boolean>(true)

  const projection = geoEqualEarth().scale(props.scale).translate([props.cx, props.cy]).rotate([rotation, 0])

  AnimationFrame(() => {
    if (isRotate) {
      let newRotation = rotation
      if (rotation >= 360) {
        newRotation = rotation - 360
      }
      setRotation(newRotation + 0.2)
    }
  })

  function

returnProjectionValueWhenValid(point: [number, number], index: number) {
    const retVal: [number, number] | null = projection(point)
    if (retVal?.length) {
      return retVal[index]
    }
    return 0
  }

  const handleMarkerClick = (index: number) => {
    props.setSelectedItem(props.clientsData[index])
    setIsRotate(false)
  }

  return (
    <>
      <svg width={props.scale * 3} height={props.scale * 3} viewBox="0 0 800 450" onMouseMove={() => setIsRotate(false)} onMouseOut={() => setIsRotate(true)}>
        <g>
          {(props.mapData.mapFeatures as []).map((d, i) => (
            <path
              key={`path-${uuid()}`}
              d={geoPath().projection(projection)(d) as string}
              fill={`rgba(30,50,50,${(1 / (props.mapData.mapFeatures ? props.mapData.mapFeatures.length : 0)) * i})`}
              stroke="aliceblue"
              strokeWidth={props.rotationSpeed}
            />
          ))}
        </g>
        <g>
          {props.clientsData.map((d, i) => {
            return props.selectedItem.id !== d.id ? (
              <circle
                style={{ cursor: 'pointer' }}
                key={`marker-${uuid()}`}
                cx={returnProjectionValueWhenValid([d.latitude, d.longitude], 0)}
                cy={returnProjectionValueWhenValid([d.latitude, d.longitude], 1)}
                r="8"
                fill="rgba(242, 121, 53, 1)"
                stroke="#FFFFFF"
                onClick={() => handleMarkerClick(i)}
              />
            ) : (
              <PulsatingCircle key={`pulsatingCircle-${uuid()}`} cx={returnProjectionValueWhenValid([d.latitude, d.longitude], 0)} cy={returnProjectionValueWhenValid([d.latitude, d.longitude], 1)} />
            )
          })}
        </g>
      </svg>
    </>

  )
}

export default WorldMap

interface IWorldMapProps {
  mapData: Types.MapObject
  clientsData: Types.ClientData[]
  setSelectedItem: Function
  selectedItem: clientsObject
  scale: number
  cx: number
  cy: number
  initRotation: number
  rotationSpeed: number
}

如果我们将第六章的的WorldMap.tsx与本章最新的WorldMap.tsx进行比较,我们会发现有一些变化。

对于函数props的接口签名,我将根据我们创建的clientObjectmapObject设置props,并传递一个函数,一旦用户单击地图上选定的点,我们就可以使用该函数进行回调。

interface IWorldMapProps {
  mapData: Types.MapObject
  clientsData: Types.ClientData[]
  setSelectedItem: Function
  selectedItem: clientsObject
  scale: number
  cx: number
  cy: number
  initRotation: number
  rotationSpeed: number
}

在渲染中,最大的变化是坐标点,我在上一章中设置的,如果它们被选中,将被检查。

我为每个点返回一个圆或者一个PulsatingCircle分量。

return (
    <>
      <svg width={props.scale * 3} height={props.scale * 3} viewBox="0 0 800 450" onMouseMove={() => setIsRotate(false)} onMouseOut={() => setIsRotate(true)}>
        <g>
          {(props.mapData.mapFeatures as []).map((d, i) => (
            <path
              key={`path-${uuid()}`}
              d={geoPath().projection(projection)(d) as string}
              fill={`rgba(30,50,50,${(1 / (props.mapData.mapFeatures ? props.mapData.mapFeatures.length : 0)) * i})`}
              stroke="aliceblue"
              strokeWidth={props.rotationSpeed}
            />
          ))}
        </g>
        <g>

如果选择了一个点,我将使用一个自定义组件来创建一个脉动的圆。没错,我将使用我们在第二章中创建的代码来创建一个动画脉动圈。看一看:

          {props.clientsData.map((d, i) => {
            return props.selectedItem.id !== d.id ? (
              <circle
                style={{ cursor: 'pointer' }}
                key={`marker-${uuid()}`}
                cx={returnProjectionValueWhenValid([d.latitude, d.longitude], 0)}
                cy={returnProjectionValueWhenValid([d.latitude, d.longitude], 1)}
                r="8"
                fill="rgba(242, 121, 53, 1)"
                stroke="#FFFFFF"
                onClick={() => handleMarkerClick(i)}
              />
            ) : (
              <PulsatingCircle key={`pulsatingCircle-${uuid()}`} cx={returnProjectionValueWhenValid([d.latitude, d.longitude], 0)} cy={returnProjectionValueWhenValid([d.latitude, d.longitude], 1)} />
            )
          })}
        </g>
      </svg>
    </>

  )
}

我用的是geoEqualEarth,但是你可以试试geoOrthographic或者其他任何 D3 地理投影形状。

在 JSX 代码中使用selectedItem是至关重要的,因为它将确保当一个新的客户端被选择时,地图被渲染。

至于渲染,我将使用geoEqualEarth进行投影,使用window.requestAnimationFrame制作动画;与前一章相比,这里没有什么变化。

const projection = geoEqualEarth().scale(props.scale).translate([props.cx, props.cy]).rotate([rotation, 0])

AnimationFrame(() => {
  if (isRotate) {
    let newRotation = rotation
    if (rotation >= 360) {
      newRotation = rotation - 360
    }
    setRotation(newRotation + 0.2)
  }
})

handleMarkerClick处理器将客户端数据对象传递回父组件ClientsWidget.tsx并停止地图的旋转。

const handleMarkerClick = (index: number) => {
  props.setSelectedItem(props.clientsData[index])
  setIsRotate(false)
}

请注意,ClientListClientListDetail使用setSelectedClient来设置选定的客户端。我可以在这里使用反冲原子状态来避免钻取,但是,因为它不会钻取任何不需要数据的组件。有一种方法来处理这个问题是很好的,而且是帮助调试和避免麻烦的更安全的方法。

Avoid prop drilling

在 React 中,一切都是组件,数据通过props自上而下(从父到子)传递。假设您需要一个父组件的子组件的子组件中的属性。你是做什么的?您可以将属性从一个组件传递到另一个组件。使用层次结构中更高的另一个组件提供的数据来深度嵌套组件的技术被称为prop 钻探

正确钻孔的主要缺点是,原本不应该知道数据的组件变得不必要的复杂和麻烦。它们也更难维护,因为现在我们必须在测试中添加它们(如果我们可以测试的话),并试图找出提供数据的父组件。

脉动圈. tsx

React 大放异彩。我可以在 Material-UI 和样式组件的帮助下使用 JSX,而不是一些复杂的 D3 编码。我需要通过cxcy,这样我的脉动圈就会随着旋转图移动。

import React from 'react'
import styled, { keyframes } from 'styled-components'const circlePulse = (colorOne: string, colorTwo: string) => keyframes`
0% {
  fill:${colorOne};
  stroke-width:20px
}
50% {
  fill:${colorTwo};
  stroke-width:2px
}
100%{
  fill:${colorOne};
  stroke-width:20px
}
`
const StyledInnerCircle = styled.circle`
  animation: ${() => circlePulse('rgb(245,197,170)', 'rgba(242, 121, 53, 1)')} infinite 4s linear;
`export default function PulsatingCircle(props: IPulsatingCircle) {
  return (
    <>
      <StyledInnerCircle cx={props.cx} cy={props.cy} r="8" stroke="limegreen" stroke-width="5" />
    </>

  )
}interface IPulsatingCircle {
  cx: number
  cy: number
}

ClientList.tsx 子组件

ClientList.tsx是直截了当的素材——有风格的 UI。我正在制作一个代表我工作过的公司的标志列表,并允许滚动和选择。所选的项目将被传递回父组件,以便可以更新所有其他组件。看一下完整的代码:

// src/component/ClientList/ClientList.tsx
import React from 'react'
import './ClientList.scss'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import List from '@material-ui/core/List'
import ListItem from '@material-ui/core/ListItem'
import ListItemAvatar from '@material-ui/core/ListItemAvatar'
import { clientsObject } from '../../model'

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    root: {
      width: '150px',
      maxWidth: 150,
      backgroundColor: theme.palette.background.paper,
      maxHeight: '340px',
      overflow: 'auto',
      paddingTop: '5px',
      scroll: 'paper',
    },
  })
)

const ClientList = (props: IClientListProps) => {
  const handleClick = (id: number) => {
    // console.log(`id: ${id}`)
    props.setSelectedItem(props.data[id])
  }
  const classes = useStyles()
  return (
    <List dense className={classes.root}>
      {props.data.map((value) => {
        return (
          <ListItem key={value.id} button onClick={() => handleClick(value.id - 1)}>
            <ListItemAvatar>
              <img alt={`${value.name} avatar`} src={`/clients-logo/${value.logo}`} width="100px" />
            </ListItemAvatar>
          </ListItem>
        )
      })}
    </List>
  )
}

interface IClientListProps {
  data: clientsObject[]
  setSelectedItem: Function

}

export default ClientList

让我们回顾一下代码。

对于样式,我使用了 Material-UI 主题和样式,并将组件设置为可滚动,这样用户就可以滚动浏览客户列表。

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    root: {
      width: '150px',
      maxWidth: 150,
      backgroundColor: theme.palette.background.paper,
      maxHeight: '340px',
      overflow: 'auto',
      paddingTop: '5px',
      scroll: 'paper',
    },
  })
)

函数签名IClientListProps包括传递用户手势的setSelectedItem函数,该手势指示列表上的项目被选择,以及客户端数据的数据馈送。

const ClientList = (props: IClientListProps) => {
  const handleClick = (id: number) => {
    props.setSelectedItem(props.data[id])
  }
  const classes = useStyles()
  return (
    <List dense className={classes.root}>

我使用数组映射属性来迭代每个数据并绘制一个ListItemListItem包括映射到public/logo文件夹的徽标和点击处理程序。

      {props.data.map((value) => {
        return (
          <ListItem key={value.id} button onClick={() => handleClick(value.id - 1)}>
            <ListItemAvatar>
              <img alt={`${value.name} avatar`} src={`/clients-logo/${value.logo}`} width="100px" />
            </ListItemAvatar>
          </ListItem>
        )
      })}
    </List>
  )
}

最后,IClientListProps prop 接口包括从ClientsWidget.tsx父组件传递来的客户端数据提要和所选项方法。

interface IClientListProps {
  data: clientsObject[]
  setSelectedItem: Function
}

ClientListDetail.tsx 子组件

对于ClientListDetail,我正在设置客户的详细信息以及我的个人资料头像图片和对他们项目的贡献。以下是完整的代码:

// src/component/ClientListDetail/ClientListDetail.tsx
import React from 'react'
import './ClientListDetail.scss'
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import ListItem from '@material-ui/core/ListItem'
import ListItemText from '@material-ui/core/ListItemText'
import { Button, Typography } from '@material-ui/core'
import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'
import ChevronRightIcon from '@material-ui/icons/ChevronRight'
import { clientsObject } from '../../model'

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    root: {
      width: '500px',
      backgroundColor: theme.palette.background.paper,
      position: 'absolute',
      top: (props) => `${(props as IClientListDetailProps).paddingTop}px`,
      paddingLeft: '0px',
    },
    inline: {
      display: 'inline',
    },
    button: {
      margin: theme.spacing(0),
    },
  })
)

const profileImage = require('../../assets/about/EliEladElrom.jpg')

const ClientListDetail = (props: IClientListDetailProps) => {
  const classes = useStyles(props)
  const handleNext = () => {
    const index = props.data.indexOf(props.selectedItem)
    let nextItem
    if (index < props.data.length - 1) {
      nextItem = props.data[index + 1]
    } else {
      // eslint-disable-next-line prefer-destructuring
      nextItem = props.data[0]
    }
    props.setSelectedItem(nextItem)
  }
  const handlePrevious = () => {
    const index = props.data.indexOf(props.selectedItem)
    let nextItem
    if (index > 0) {
      nextItem = props.data[index - 1]
    } else {
      nextItem = props.data[props.data.length - 1]
    }
    props.setSelectedItem(nextItem)
  }
  return (
    <div className={classes.root}>
      <ListItem>
        <Button
          size="medium"
          color="primary"
          className={classes.button}
          startIcon={<ChevronLeftIcon />}
          onClick={() => {
            handlePrevious()
          }}
        />
        <img className="about-image" src={profileImage} alt="Eli Elad Elrom" />
        <ListItemText
          primary={props.selectedItem?.name}
          secondary={
            <>
              <Typography component="span" variant="body2" className={classes.inline} color="textPrimary">
                {props.selectedItem?.city}, {props.selectedItem?.state} {props.selectedItem?.country}
              </Typography>
              <br />
              Eli helped {props.selectedItem?.name} with - {props.selectedItem?.description} - visit them on the web:{' '}
              <a href={props.selectedItem?.website} target="_blank" rel="noopener noreferrer">
                {props.selectedItem?.website}
              </a>
            </>

          }
        />
        <Button
          size="medium"
          color="primary"
          className={classes.button}
          startIcon={<ChevronRightIcon />}
          onClick={() => {
            handleNext()
          }}
        />
      </ListItem>
    </div>
  )
}

interface IClientListDetailProps {
  selectedItem: clientsObject

  setSelectedItem: Function
  data: clientsObject[]
  // eslint-disable-next-line react/no-unused-prop-types
  paddingTop: number
}

export default ClientListDetail

我们来复习一下。

我使用material-ui样式来传递顶部的填充,这样我可以调整这个子组件。

 import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
import ListItem from '@material-ui/core/ListItem'
import ListItemText from '@material-ui/core/ListItemText'
import { Button, Typography } from '@material-ui/core'
import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'
import ChevronRightIcon from '@material-ui/icons/ChevronRight'
import { clientsObject } from '../../model'

注意,我在顶部为从父组件通过props传递的组件设置了填充。props被传递给useStyle方法,它们可以被动态使用。

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    root: {
      width: '500px',
      backgroundColor: theme.palette.background.paper,
      position: 'absolute',
      top: (props) => `${(props as IClientListDetailProps).paddingTop}px`,
      paddingLeft: '0px',
    },
    inline: {
      display: 'inline',
    },
    button: {
      margin: theme.spacing(0),
    },
  })
)

const profileImage = require('../../assets/about/EliEladElrom.jpg')

ClientListDetail组件包括左箭头和右箭头以及使用这些箭头在客户机中导航的方法。看看handleNexthandlePrevious

对于ClientListDetail组件签名,我们需要props接口,它将包含所选项目数据以及设置所选项目的函数。

const ClientListDetail = (props: IClientListDetailProps) => {

props被传递给useStyles以便使用。

  const classes = useStyles(props)
  const handleNext = () => {
    const index = props.data.indexOf(props.selectedItem)
    let nextItem
    if (index < props.data.length - 1) {
      nextItem = props.data[index + 1]
    } else {
      nextItem = props.data[0]
    }
    props.setSelectedItem(nextItem)
  }
  const handlePrevious = () => {
    const index = props.data.indexOf(props.selectedItem)
    let nextItem
    if (index > 0) {
      nextItem = props.data[index - 1]
    } else {
      nextItem = props.data[props.data.length - 1]
    }
    props.setSelectedItem(nextItem)
  }

细节组件被包装在一个ListItem中,包括材质-UI 图标按钮、照片和选定的项目细节。

  return (
    <div className={classes.root}>
      <ListItem>
        <Button
          size="medium"
          color="primary"
          className={classes.button}
          startIcon={<ChevronLeftIcon />}
          onClick={() => {
            handlePrevious()
          }}
        />
        <img className="about-image" src={profileImage} alt="Eli Elad Elrom" />
        <ListItemText
          primary={props.selectedItem?.name}
          secondary={
            <>

              <Typography component="span" variant="body2" className={classes.inline} color="textPrimary">
                {props.selectedItem?.city}, {props.selectedItem?.state} {props.selectedItem?.country}
              </Typography>
              <br />
              Eli helped {props.selectedItem?.name} with - {props.selectedItem?.description} - visit them on the web:{' '}
              <a href={props.selectedItem?.website} target="_blank" rel="noopener noreferrer">
                {props.selectedItem?.website}
              </a>
              </>

          }
        />
        <Button
          size="medium"
          color="primary"
          className={classes.button}
          startIcon={<ChevronRightIcon />}
          onClick={() => {
            handleNext()
          }}
        />
      </ListItem>
    </div>
  )
}

IClientListDetailProps props界面包括选择的项目、设置选择的项目的方法、客户端数据馈送和填充。

interface IClientListDetailProps {
  selectedItem: clientsObject
  setSelectedItem: Function
  data: clientsObject
  paddingTop: number

}

ClientsWidget 组件

ClientWidget 组件是父组件,它将借助反冲获取地图和客户端列表的结果,并将结果传递给子组件。

我正在使用 Material-UI 网格组件来设置一个客户列表和一个旋转的世界地图。我设置的网格将有一个容器和两列。以下是有助于理解结构的布局:

import Grid from '@material-ui/core/Grid'
<Grid container>
     <Grid item xs={6}>
         ...
     </Grid>
     <Grid item xs={6}>
         ...
     </Grid>
</Grid>

看一看ClientsWidget.tsx完整代码:

// src/widgets/ClientsWidget/ClientsWidget.tsx
import React, { useEffect, useState } from 'react'
import './ClientsWidget.scss'
import { useRecoilValue } from 'recoil'
import { Grid } from '@material-ui/core'
import { getPreviousClientListData } from '../../recoil/selectors/clientsSelectors'
import { clientsObject, mapObject } from '../../model'
import { getMapData } from '../../recoil/selectors/mapSelectors'
import WorldMap from '../../components/WorldMap/WorldMap'
import ClientListDetail from '../../components/ClientListDetail/ClientListDetail'
import ClientList from '../../components/ClientList/ClientList'

const ClientsWidget = () => {
  const clientsData: clientsObject = useRecoilValue(getPreviousClientListData) as clientsObject
  const mapData: mapObject = useRecoilValue(getMapData) as mapObject

  const [selectedItem, setSelectedItem] = useState<clientsObject>(clientsData[0])

  useEffect(() => {
    // results
    // console.log(`Result: ${JSON.stringify(clientsData)}`)
    // console.log(`Result: ${JSON.stringify(mapResults)}`)
  })
  return (
    <>

      {clientsData?.length > 0 && mapData.mapFeatures.length > 0 ? (
        <>

          <Grid container>
            <Grid item xs={3}>
              <ClientList data={clientsData} setSelectedItem={setSelectedItem} />
            </Grid>
            <Grid item xs={8}>
              <WorldMap mapData={mapData} clientsData={clientsData} selectedItem={selectedItem} setSelectedItem={setSelectedItem} scale={200} cx={0} cy={100} initRotation={100} rotationSpeed={0.3} />
            </Grid>
          </Grid>
          <ClientListDetail selectedItem={selectedItem} data={clientsData} setSelectedItem={setSelectedItem} paddingTop={400} />
        </>

      ) : (
        <>Loading!</>
      )}
    </>

  )
}
export default ClientsWidget

我们来复习一下。

我正在把反冲选择器的数据传送过来。

  const clientsData: clientsObject[] = useRecoilValue(getPreviousClientListData) as clientsObject[]
  const mapData: mapObject = useRecoilValue(getMapData) as mapObject

对于所选的项目,我正在使用状态。

  const [selectedItem, setSelectedItem] = useState<clientsObject>(clientsData[0])

对于 JSX 渲染,我正在检查以确保数据被上传,然后包括ClientListWorldMapClientListDetail子组件。

  return (
    <>

      {clientsData?.length > 0 && mapData.mapFeatures.length > 0 ? (
    <>

          ...
    </>

      ) : (
        <>Loading!</>
      )}
    </>

  )
}

App.tsx

最后一步,不要忘记将小部件添加到父组件App.tsx

// src/App.tsx

import React from 'react'
import './App.scss'
import ClientsWidget from './widgets/ClientsWidget/ClientsWidget'

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <ClientsWidget />
      </header>
    </div>
  )
}

export default App

节目质量监视

现在,如果您运行formatlinttest命令,我们应该得到一个整洁的确认,我们都是好的。

$ yarn format && yarn lint

这将为您提供以下结果:

$ eslint --ext .js,.jsx,.ts,.tsx ./
✨  Done in x seconds.

WorldMap.test.tsx

对于 Jest 酶测试,我将确保组件已安装。

从代码中可以看出,使用initClientsObjectinitClientsObject来设置初始值非常方便。

// src/component/WorldMap/WorldMap.test.tsx

import React from 'react'
import { shallow } from 'enzyme'
import WorldMap from './WorldMap'
import { initClientsObject, initMapObject } from '../../model'

describe('<WorldMap />', () => {
  let component

  beforeEach(() => {
    component = shallow(<WorldMap mapData={initMapObject()} clientsData={[initClientsObject()]} selectedItem={initClientsObject()} setSelectedItem={Function} scale={200} cx={0} cy={100} initRotation={100} rotationSpeed={0.3} />)
  })

  test('It should mount', () => {
    expect(component.length).toBe(1)
  })
})

运行测试。

$ yarn test

您可以将您的结果与我的进行比较(参见图 7-4 )。

img/510438_1_En_7_Fig4_HTML.jpg

图 7-4

世界地图小部件测试结果

您可以从这里下载该项目:

https://github.com/Apress/integrating-d3.js-with-react/tree/main/ch07/world-map-widget

摘要

在本章中,我向您展示了如何使用我们在上一章中创建的世界地图作为基础来构建一个工作小部件,以使用 D3、React v17、Material-UI、反冲和 TypeScript 来显示客户列表。

用 React 配合 D3 是纯善。我能够创建组件,并在需要时让 React 管理 DOM 的状态和更新。它需要很少的 D3 代码,但很有用,因为 JSX 可以处理大部分代码,我们的前端代码是可读的,并由 VDOM 管理。反冲有助于保持状态,仅在需要时渲染。

如您所见,将 D3 与 React 结合使用有助于创建简洁的可视化工具,从而更直观地展示信息。

最后,我能够运行formatlinttest来确保质量。

我希望这一章能启发你创建自己的互动简历,展示工作、客户名单、相册或任何你想突出的东西。

正如您在本章中看到的,使用这种小部件、状态管理和组件的结构,并设置数据的类型和模型,有助于创建可读和可测试的代码。一旦需要更改,您将能够轻松地重构和添加特性。

在下一章,我们将在 React 组件的帮助下创建一个 D3 力图。