使用 Mapbox、React 和 Cube.js 构建基于地图的数据可视化🗺数据集和 API 前端和 Mapbox 热图可视化动态点可视化数据模式点和事件可视化分级统计图可视化辉煌的结局

2025-05-24

使用 Mapbox、React 和 Cube.js 构建基于地图的数据可视化

数据集和 API

前端和 Mapbox

热图可视化

动态点可视化

数据模式

点和事件可视化

等值线可视化

辉煌的结局

简而言之:我会讲解如何构建一个视觉上吸引人且运行速度快、包含各种地图的 Web 应用。这会很有趣。


嘿,开发者们👋

您很可能知道,数据可视化的方法有很多种,但是当涉及基于位置(或地理空间)的数据时,基于地图的数据可视化是最易于理解和图形化的。

在本指南中,我们将探讨如何使用Mapbox(一套非常流行的用于处理地图、导航和基于位置的搜索等的工具)通过 JavaScript(和 React)构建地图数据可视化。

我们还将学习如何使地图数据可视化具有交互性(或动态性),从而允许用户控制在地图上可视化哪些数据。

这是我们今天的计划:

那么……你想知道结果会是什么样子吗?还不错,对吧?

替代文本

为了让本指南更加有趣,我们将使用Stack Overflow开放数据集,该数据集在Google BigQueryKaggle上公开发布。借助此数据集,我们将能够找到以下问题的答案:

  • Stack Overflow 用户住在哪里?
  • Stack Overflow 用户的位置和他们的评分之间是否存在关联?
  • Stack Oerflow 用户的总体和平均评分(按国家/地区)是多少?
  • 提问者和回答问题的人的地点有什么区别吗?

此外,为了通过 API 托管和提供此数据集,我们将使用PostgreSQL作为数据库,并使用Cube.js作为分析 API 平台,这样可以在几分钟内为分析应用程序引导后端。

这就是我们的计划——让我们开始行动吧!🤘

如果你迫不及待地想了解它的构建方式,欢迎随时研究GitHub 上的演示源代码。否则,我们继续吧。

数据集和 API

原始Stack Overflow 数据集包含文本字符串形式的位置信息。然而,Mapbox 最适合使用GeoJSON编码的位置信息,GeoJSON 是基于 JSON 的地理特征开放标准(惊喜!)。

替代文本

这就是为什么我们使用Mapbox Search API进行地理编码的原因。由于地理编码过程与地图数据可视化无关,我们仅提供嵌入 GeoJSON 数据的现成数据集。

设置数据库🐘

我们将使用 PostgreSQL(一款优秀的开源数据库)来存储 Stack Overflow 数据集。请确保您的系统上已安装PostgreSQL。

首先,下载数据集⬇️(文件大小约为 600 MB)。

stackoverflow__example然后,使用以下命令创建数据库:

$ createdb stackoverflow__example
$ psql --dbname stackoverflow__example -f so-dataset.sql
Enter fullscreen mode Exit fullscreen mode

设置 API 📦

让我们使用Cube.js(一个开源分析 API 平台)通过 API 提供此数据集。运行以下命令:

$ npx cubejs-cli create stackoverflow__example -d postgres
Enter fullscreen mode Exit fullscreen mode

Cube.js 使用环境变量进行配置。要建立与数据库的连接,我们需要指定数据库类型和名称。

在新创建的stackoverflow__example文件夹中,请将 .env 文件的内容替换为以下内容:

CUBEJS_DEVELOPER_MODE=true
CUBEJS_API_SECRET=SECRET
CUBEJS_DB_TYPE=postgres
CUBEJS_DB_NAME=stackoverflow__example
CUBEJS_DB_USER=postgres
CUBEJS_DB_PASS=postgres
Enter fullscreen mode Exit fullscreen mode

现在我们准备使用这个简单的命令启动 API:

$ npm run dev
Enter fullscreen mode Exit fullscreen mode

要检查 API 是否正常工作,请在浏览器中访问http://localhost:4000 。您将看到 Cube.js 开发者游乐场,这是一个功能强大的工具,可以极大地简化数据探索和查询构建。

替代文本

使 API 工作的最后一件事是定义数据模式:它描述了我们的数据集中有哪些类型的数据以及我们的应用程序中应该提供哪些数据。

让我们进入数据模式页面,检查数据库中的所有表。然后点击加号图标,并按下“生成模式”按钮。瞧!🎉

现在您可以在文件夹中发现许多新*.js文件schema

因此,我们的 API 已设置完毕,我们准备使用 Mapbox 创建地图数据可视化!

前端和 Mapbox

好了,现在是时候编写一些 JavaScript 代码,创建地图数据可视化的前端部分了。与数据模式一样,我们可以使用 Cube.js 开发者园地轻松搭建它。

前往模板页面,选择一个预定义模板,或点击“创建您自己的模板”。本指南将使用 React,因此请根据实际情况选择。

花费几分钟安装所有依赖项(哦,就是这些node_modules)后,您将获得新的dashboard-app文件夹。使用以下命令运行此应用程序:

$ cd dashboard-app
$ npm start 
Enter fullscreen mode Exit fullscreen mode

太棒了!现在我们可以将 Mapbox 添加到我们的前端应用程序了。

设置 Mapbox

我们将使用react-map-gl包装器来与 Mapbox 配合使用。实际上,你可以在Mapbox 文档中找到一些适用于 React、Angular 和其他框架的插件

让我们react-map-gl用这个命令来安装:

$ npm install --save react-map-gl
Enter fullscreen mode Exit fullscreen mode

要将此包连接到我们的前端应用程序,请src/App.jsx用以下内容替换:

import * as React from 'react';
import { useState } from 'react';
import MapGL from 'react-map-gl';

const MAPBOX_TOKEN = 'MAPBOX_TOKEN';

function App() {
  const [ viewport, setViewport ] = useState({
    latitude: 34,
    longitude: 5,
    zoom: 1.5,
  });

  return (
    <MapGL
      {...viewport}
      onViewportChange={(viewport) => {
        setViewport(viewport)
      }}
      width='100%'
      height='100%'
      mapboxApiAccessToken={MAPBOX_TOKEN}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

可以看到MAPBOX_TOKEN需要从Mapbox获取并放入这个文件中。

请参阅Mapbox 文档,或者,如果您已经有 Mapbox 帐户,只需在帐户页面生成它。

此时,我们有了一张空白的世界地图,可以开始可视化数据了。太棒了!

规划地图数据可视化

以下是使用 Mapbox 和 Cube.js实现地图数据可视化的方法:

  • 使用Cube.js将数据加载到前端
  • 将数据转换为 GeoJSON 格式
  • 将数据加载到 Mapbox 图层
  • properties可选地,使用对象设置数据驱动的样式和操作来自定义地图

在本指南中,我们将遵循此路径并创建四个独立的地图数据可视化:

  • 基于用户位置数据的热图层
  • 具有数据驱动样式和动态更新数据源的点层
  • 带有点击事件的点层
  • 基于不同计算和数据驱动样式的等值线图层

开始黑客攻击吧!😎

热图可视化

好的,让我们创建第一个地图数据可视化!1️⃣

热力图层非常适合展示数据的分布和密度。因此,我们将用它来展示 Stack Overflow 用户的居住地。

数据模式

这个组件需要一个相当简单的模式,因为我们只需要“用户位置坐标”这样的维度和“计数”这样的度量。

然而,一些 Stack Overflow 用户拥有一些令人惊奇的位置信息,例如“在云端”、“星际运输站”或“在遥远的服务器上”。令人惊讶的是,我们无法将所有这些奇特的位置信息都转换为 GeoJSON 格式,因此我们使用 SQLWHERE子句仅选择地球上的用户。🌎

schema/Users.js文件看起来应该是这样的:

cube(`Users`, {
  sql: `SELECT * FROM public.Users WHERE geometry is not null`,

  measures: {
    count: {
      type: `count`
    }
  },

  dimensions: {
    geometry: {
      sql: 'geometry',
      type: 'string'
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

Web 组件

另外,我们需要dashboard-app/src/components/Heatmap.js以下源代码的组件。让我们分解一下它的内容!

首先,我们使用方便的Cube.js 钩子将数据加载到前端

const { resultSet } = useCubeQuery({ 
  measures: ['Users.count'],
  dimensions: ['Users.geometry'],
});
Enter fullscreen mode Exit fullscreen mode

为了加快地图渲染速度,我们通过此查询按用户位置对其进行分组。

然后,我们将查询结果转换为 GeoJSON 格式:

let data = {
  type: 'FeatureCollection',
  features: [],
};

if (resultSet) {
  resultSet.tablePivot().map((item) => {
    data['features'].push({
      type: 'Feature',
      properties: {
        value: parseInt(item['Users.count']),
      },
      geometry: JSON.parse(item['Users.geometry']),
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

之后,我们将这些数据输入到 Mapbox。使用react-map-gl,我们可以这样做:

  return (
    <MapGL
      width='100%'
      height='100%'
      mapboxApiAccessToken={MAPBOX_TOKEN}>
      <Source type='geojson' data={data}>
        <Layer {...{
          type: 'heatmap',
          paint: {
            'heatmap-intensity': intensity,
            'heatmap-radius': radius,
            'heatmap-weight': [ 'interpolate', [ 'linear' ], [ 'get', 'value' ], 0, 0, 6, 2 ],
            'heatmap-opacity': 1,
          },
        }} />
      </Source>
    </MapGL>
  );
}
Enter fullscreen mode Exit fullscreen mode

请注意,这里我们使用 Mapbox 数据驱动样式:我们将heatmap-weight属性定义为表达式,它取决于“properties.value”:

'heatmap-weight': [ 'interpolate', ['linear'], ['get', 'value'], 0, 0, 6, 2]
Enter fullscreen mode Exit fullscreen mode

您可以在Mapbox 文档中找到有关表达式的更多信息

这是我们构建的热图:

替代文本

有用的链接

动态点可视化

下一个问题是:Stack Overflow 用户的位置和他们的评分之间是否存在关联?2️⃣

剧透警告:没有,没有😜。但这是一个好问题,有助于理解动态数据加载的工作原理,并深入研究Cube.js的过滤器。

数据模式

我们需要调整schema/User.js数据模式使其看起来像这样:

cube('Users', {
  sql: 'SELECT * FROM public.Users WHERE geometry is not null',

  measures: {
    max: {
      sql: 'reputation',
      type: 'max',
    },

    min: {
      sql: 'reputation',
      type: 'min',
    }
  },

  dimensions: {
    value: {
      sql: 'reputation',
      type: 'number'

    },

    geometry: {
      sql: 'geometry',
      type: 'string'
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

Web 组件

另外,我们需要dashboard-app/src/components/Points.js以下源代码的组件。让我们分解一下它的内容!

首先,我们需要查询 API 来找出用户声誉的初始范围:

const { resultSet: range } = useCubeQuery({
    measures: ['Users.max', 'Users.min']
});

useEffect(() => {
  if (range) {
    setInitMax(range.tablePivot()[0]['Users.max']);
    setInitMin(range.tablePivot()[0]['Users.min']);
    setMax(range.tablePivot()[0]['Users.max']);
    setMin(range.tablePivot()[0]['Users.max'] * 0.4);
  }
}, [range]);
Enter fullscreen mode Exit fullscreen mode

然后,我们使用Ant DesignSlider (一个很棒的开源 UI 工具包)创建一个组件。每当此 Slider 的值发生变化时,前端都会向数据库发出请求:

const { resultSet: points } = useCubeQuery({
  measures: ['Users.max'],
  dimensions: ['Users.geometry'],
  filters: [
    {
      member: "Users.value",
      operator: "lte",
      values: [ max.toString() ]
    },
    {
      member: "Users.value",
      operator: "gte",
      values: [ min.toString() ]
    }
  ]
});
Enter fullscreen mode Exit fullscreen mode

为了使地图渲染速度更快,通过此查询我们根据用户位置对用户进行分组,并仅显示评分最高的用户。

然后,像前面的例子一样,我们将查询结果转换为 GeoJSON 格式:

const data = {
  type: 'FeatureCollection',
  features: [],
};

if (points) {
  points.tablePivot().map((item) => {
    data['features'].push({
      type: 'Feature',
      properties: {
        value: parseInt(item['Users.max']),
      },
      geometry: JSON.parse(item['Users.geometry']),
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

请注意,我们还在图层属性中应用了数据驱动样式,现在点的半径取决于评级值。

'circle-radius': { 
  property: 'value', 
  stops: [ 
    [{ zoom: 0, value: 10000 }, 2], 
    [{ zoom: 0, value: 2000000 }, 20]
  ] 
}
Enter fullscreen mode Exit fullscreen mode

当数据量适中时,也可以仅使用Mapbox 过滤器,仍能达到预期的性能。我们可以使用 Cube.js 加载一次数据,然后使用以下图层设置过滤渲染数据:

filter: [ 
  "all", 
  [">", max, ["get", "value"]], 
  ["<", min, ["get", "value"]] 
],
Enter fullscreen mode Exit fullscreen mode

这是我们构建的可视化效果:

替代文本

点和事件可视化

在这里,我们想按国家/地区显示答案和问题的分布情况,因此我们呈现了最易查看的 Stack Overflow 问题和评分最高的答案。3️⃣

当点击某个点时,我们会弹出一个包含问题信息的窗口。

数据模式

由于数据集结构,我们在表中没有用户几何信息Questions

这就是为什么我们需要在数据模式中使用连接。这是一种一对多关系,这意味着一个用户可以留下多个问题。

我们需要在文件中添加以下代码schema/Questions.js

joins: {
  Users: { 
    sql: `${CUBE}.owner_user_id = ${Users}.id`, 
    relationship: `belongsTo` 
  },
},
Enter fullscreen mode Exit fullscreen mode

Web 组件

然后,我们需要dashboard-app/src/components/ClickEvents.js组件包含以下源代码。以下是最重要的亮点!

获取问题数据的查询:

{
  measures: [ 'Questions.count' ],
  dimensions: [ 'Users.geometry']
}
Enter fullscreen mode Exit fullscreen mode

然后我们使用一些非常简单的代码将数据转换为 geoJSON:

const data = { 
  type: 'FeatureCollection',
  features: [], 
};

resultSet.tablePivot().map((item) => {
  data['features'].push({
    type: 'Feature',
    properties: {
      count: item['Questions.count'],
      geometry: item['Users.geometry'],
    },
    geometry: JSON.parse(item['Users.geometry'])
  });
}); 
Enter fullscreen mode Exit fullscreen mode

下一步是捕获点击事件并加载点数据。以下代码特定于react-map-gl包装器,但逻辑只是监听地图点击事件并按图层 ID 进行过滤:


const [selectedPoint, setSelectedPoint] = useState(null);

const { resultSet: popupSet } = useCubeQuery({
  dimensions: [
    'Users.geometry',
    'Questions.title',
    'Questions.views',
    'Questions.tags'
  ],
  filters: [ {
    member: "Users.geometry",
    operator: "contains",
    values: [ selectedPoint ]
  } ],
}, { skip: selectedPoint == null });


const onClickMap = (event) => {
  setSelectedPoint(null);
  if (typeof event.features != 'undefined') {
    const feature = event.features.find(
      (f) => f.layer.id == 'questions-point'
    );
    if (feature) {
      setSelectedPoint(feature.properties.geometry);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

当我们捕获某个点上的点击事件时,我们会请求按点位置过滤的问题数据并更新弹出窗口。

因此,这就是我们辉煌的成果:

替代文本

等值线可视化

最后,是等值线图。这种类型的地图适合区域统计,所以我们将用它来可视化按国家/地区划分的用户总数和平均排名。4️⃣

数据模式

为了实现这一点,我们需要通过一些传递连接使我们的模式稍微复杂化一些。

首先,让我们更新schema/Users.js文件:

 cube('Users', {
  sql: 'SELECT * FROM public.Users',
  joins: {
    Mapbox: {
      sql: '${CUBE}.country = ${Mapbox}.geounit',
      relationship: 'belongsTo',
    },
  },
  measures: {
    total: {
      sql: 'reputation',
      type: 'sum',
    }
  },

  dimensions: {
    value: {
      sql: 'reputation',
      type: 'number'
    },

    country: {
      sql: 'country',
      type: 'string'
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

下一个文件是schema/Mapbox.js,它包含国家代码和名称:

cube(`Mapbox`, {
  sql: `SELECT * FROM public.Mapbox`,

  joins: {
    MapboxCoords: {
      sql: `${CUBE}.iso_a3 = ${MapboxCoords}.iso_a3`,
      relationship: `belongsTo`,
    },
  },

  dimensions: {
    name: {
      sql: 'name_long',
      type: 'string',
    },

    geometry: {
      sql: 'geometry',
      type: 'string',
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

接下来schema/MapboxCoords.js显然保存了用于地图渲染的多边形坐标:

cube(`MapboxCoords`, {
  sql: `SELECT * FROM public.MapboxCoords`,

  dimensions: {
    coordinates: {
      sql: `coordinates`,
      type: 'string',
      primaryKey: true,
      shown: true,
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

请注意,我们有一个加入schema/Mapbox.js

MapboxCoords: {
  sql: `${CUBE}.iso_a3 = ${MapboxCoords}.iso_a3`, 
  relationship: `belongsTo`,
},
Enter fullscreen mode Exit fullscreen mode

另一个是schema/User.js

Mapbox: {
  sql: `${CUBE}.country = ${Mapbox}.geounit`,
  relationship: `belongsTo`,
}
Enter fullscreen mode Exit fullscreen mode

对于 Stack Overflow 数据集,Mapbox表中最适合的列是geounit,但在其他情况下,邮政编码或iso_a3/iso_a2可能会更好。

关于数据模式就这些了。您不需要直接将Users多维数据集与MapboxCoords多维数据集连接起来。Cube.js 会帮您完成所有连接。

Web 组件

代码包含在dashboard-app/src/components/Choropleth.js组件中。最后一次分解:

查询非常简单:我们有一个计算用户排名总和的指标。

const { resultSet } = useCubeQuery({
  measures: [ `Users.total` ],
  dimensions: [ 'Users.country', 'MapboxCoords.coordinates' ]
});
Enter fullscreen mode Exit fullscreen mode

然后我们需要将结果转换为 geoJSON:

if (resultSet) {
  resultSet
    .tablePivot()
    .filter((item) => item['MapboxCoords.coordinates'] != null)
    .map((item) => {
      data['features'].push({
        type: 'Feature',
        properties: {
          name: item['Users.country'],
          value: parseInt(item[`Users.total`])
        },
        geometry: {
          type: 'Polygon',
          coordinates: [ item['MapboxCoords.coordinates'].split(';').map((item) => item.split(',')) ]
        }
      });
    });
}
Enter fullscreen mode Exit fullscreen mode

之后,我们定义一些数据驱动的样式,以使用选定的调色板来渲染分级统计图层:

'fill-color': { 
  property: 'value',
  stops: [ 
    [1000000, `rgba(255,100,146,0.1)`], 
    [10000000, `rgba(255,100,146,0.4)`], 
    [50000000, `rgba(255,100,146,0.8)`], 
    [100000000, `rgba(255,100,146,1)`]
  ],
}
Enter fullscreen mode Exit fullscreen mode

基本上就是这样!

完成后我们将看到以下内容:

替代文本

看上去很漂亮,对吧?

辉煌的结局

所以,我们构建地图数据可视化的尝试到此结束。

替代文本

希望您喜欢本指南。如果您有任何反馈或疑问,请随时加入Slack上的 Cube.js 社区——我们很乐意为您提供帮助。

另外,如果你喜欢通过 Cube.js API 查询数据的方式,那就访问Cube.js 网站试试吧。干杯!🎉

文章来源:https://dev.to/cubejs/building-map-based-data-visualizations-with-mapbox-react-and-cube-js-4poo
PREV
Cube.js,开源仪表板框架:终极指南
NEXT
如何学习渗透测试:初学者教程