【译】用Mapbox和HTML标记Clustering properties(点聚合属性)

1,434 阅读11分钟

原文地址:blog.mapbox.com/clustering-…

新的clusterProperies功能超越了数值类性的点聚合,可以将数据维度上传到额外的更高层次上。在本指南中,我们将深入研究新的 clusterProperties 功能。我们将使用全球发电厂数据库,它是世界各地发电厂的开源数据库,以构建捕获发电厂地理分布和燃料类型的集群。您可以在此处下载数据。
如果您正在寻找一个快速示例,请查看我们的官方文档。在这里,我们将解释每个步骤。

本教程的第 1 部分快速概述了如何清理、理解和构建数据。
第 2 部分重点介绍 clusterProperties 和一般聚类的基础知识。
第 3 部分展示了如何创建 HTML 标记来可视化我们在第 2 部分中聚集的属性。

1 - 数据

要在地图上可视化准确的空间分布,您的数据应该是“位置”数据。换句话说,您的数据应该包含特定的地理空间信息。在这种特殊情况下,数据集是行的集合,同时每行代表一个点,具有纬度和经度以及要可视化的其他属性。
任何数据可视化过程的第一步都是弄清楚手头的数据是否值得可视化。您需要回答两个基本问题,例如:

  • 数据的架构是什么,哪种可视化最合适?
  • 我在数据中看到了哪些趋势、模式?
  • 我能否通过一些简单的预分析(如直方图)了解这些趋势和模式?
  • 如果没有任何即时趋势或模式,我是否仍然可以使用这些数据构建信息可视化?

这些问题并不详尽,但它们是提前思考的好方法。当您准备好进行一些初步探索时,您可以选择您喜欢的工具来快速扫描数据集中的信息。自 NICAR 以来,我一直在使用一种名为Visidata的工具来快速了解我正在使用的内容。如果您更熟悉Excel 或 R这些工具,也可以使用。

在 Visidata 中查看数据集 在 Visidata 中查看数据集

为了快速查看数据集,我喜欢使用频率表。在这种情况下,使用 F 键,我可以查看国家频率甚至燃料频率类型。

2.png

3.png Visidata 中的频率表

对于此可视化,我对查看燃料类型感兴趣。根据上面的直方图,可视化应该包括前 10 个类别,并将最后 5 个归为“其他” 。 完成并更好地了解数据后,将 CSV 文件转换为 GeoJSON 文件。如果文件太大,您可以在本地提供它或上传它。请注意,数据源的格式必须是 GeoJSON 才能进行聚类。
对于本指南,我使用csv2geojson将 CSV 转换为 GeoJSON 。由于我在本地提供它,因此我通过去除无关的属性来减小文件的大小。我们需要用clusterProperties形象化是latitude,longitude,fuel和可选country_long。

要转换为 GeoJSON:

npm install -g csv2geojson

csv2geojson global_power_plant_database.csv > global_power_plant_database.geojson

2 - 聚类属性

image.png

现在 GeoJSON 已准备就绪,让我们将其添加到我们的地图中。

mapboxgl.accessToken = 'YOUR_TOKEN';

const map = new mapboxgl.Map({
  container: 'map',
  style: 'mapbox://styles/mapbox/light-v10',
  center: [-79.381000, 43.646000],
  zoom: 12.0
});

map.on('load', () => {
  // add a clustered GeoJSON source for powerplant
  map.addSource('powerplants', {
    'type': 'geojson',
    'data': powerplant,
    'cluster': true,
    'clusterRadius': 80
  });
});

要在源上启用聚类,请将 cluster 属性设置为true并将 clusterRadius 属性设置为所需的聚类半径。
如果我们到此为止,Mapbox GL JS 会将该point_count属性添加到 GeoJSON 数据中,这将启用点聚类的默认行为(公开每个聚类的点数)。然而,这里的目标是创建一个可视化显示每个集群的发电厂不同燃料类型的分布。为了实现这一点,我们需要添加clusterProperties属性并定义我们想要跟踪的类别。
在这种情况下,我们希望跟踪水力、太阳能、风能等发电厂的数量。
因此,让我们创建过滤器来定义我们的类别。根据第 1 部分中的燃料类型频率表,我将创建 11 个类别。

const hydro = ['==', ['get', 'fuel1'], 'Hydro'];
const solar = ['==', ['get', 'fuel1'], 'Solar'];
const wind = ['==', ['get', 'fuel1'], 'Wind'];
const gas = ['==', ['get', 'fuel1'], 'Gas'];
const oil = ['==', ['get', 'fuel1'], 'Oil'];
const coal = ['==', ['get', 'fuel1'], 'Coal'];
const biomass = ['==', ['get', 'fuel1'], 'Biomass'];
const waste = ['==', ['get', 'fuel1'], 'Waste'];
const nuclear = ['==', ['get', 'fuel1'], 'Nuclear'];
const geothermal = ['==', ['get', 'fuel1'], 'Geothermal'];
const others = ['any',
  ['==', ['get', 'fuel1'], 'Cogeneration'],
  ['==', ['get', 'fuel1'], 'Storage'],
  ['==', ['get', 'fuel1'], 'Other'],
  ['==', ['get', 'fuel1'], 'Wave and Tidel'],
  ['==', ['get', 'fuel1'], 'Petcoke'],
  ['==', ['get', 'fuel1'], '']
]; 

这些是decisions expressions判断表达式。通俗地讲,他们读作“过滤任何fuel1等于x的东西”。
接下来,我们可以这样设置clusterProperties:


map.addSource('powerplants', {
  'type': 'geojson',
  'data': powerplant,
  'cluster': true,
  'clusterRadius': 80,
  'clusterProperties': { // keep separate counts for each fuel category in a cluster
    'hydro': ['+', ['case', hydro, 1, 0]],
    'solar': ['+', ['case', solar, 1, 0]],
    'wind': ['+', ['case', wind, 1, 0]],
    'gas': ['+', ['case', gas, 1, 0]],
    'oil': ['+', ['case', oil, 1, 0]],
    'coal': ['+', ['case', coal, 1, 0]],
    'biomass': ['+', ['case', biomass, 1, 0]],
    'waste': ['+', ['case', waste, 1, 0]],
    'nuclear': ['+', ['case', nuclear, 1, 0]],
    'geothermal': ['+', ['case', geothermal, 1, 0]],
    'others': ['+', ['case', others, 1, 0]]
  }
});

在这里,我们基本上设置了以我们创建的每个类别命名的新属性。对于每种类型的燃料,该表达式保留每个对应类别中每个点的计数。 您现在可以根据燃料类型创建过滤器,并可以在初始化层时访问这些计数。您可以执行以下操作来添加圆圈和文本:

const current_fuel = "hydro";

map.addLayer({
  'id': 'powerplant_cluster',
  'type': 'circle',
  'source': 'powerplants',
  'filter': [
    'all',
    ['>', ['get', current_fuel], 1],
    ['==', ['get', 'cluster'], true]
  ],
  'paint': {
    'circle-color': 'rgba(0,0,0,.6)',
    'circle-radius': [
      'step',
      ['get', current_fuel],
      20,
      100,
      30,
      750,
      40
    ],
    'circle-stroke-color': '#8dd3c7',
    'circle-stroke-width': 5
  }
});

map.addLayer({
  'id': 'powerplant_cluster_label',
  'type': 'symbol',
  'source': 'powerplants',
  'filter': [
    'all',
    ['>', ['get', current_fuel], 1],
    ['==', ['get', 'cluster'], true]
  ],
  'layout': {
    'text-field': ['number-format', ['get', current_fuel], {}],
    'text-font': ['Montserrat Bold', 'Arial Unicode MS Bold'],
    'text-size': 13
  },
  'paint': {
    'text-color': '#8dd3c7'
  }
});

这将显示包含至少 1 个水电站的每个集群,圆的半径将由集群中的水电站数量确定。您可以用任何其他能源替换“水电”以获得类似的结果。

const hydro = ['==', ['get', 'fuel1'], 'Hydro'];

map.addLayer({
  'id': 'powerplant_individual',
  'type': 'circle',
  'source': 'powerplants',
  'filter': [
    'all',
    hydro,
    ['!=', ['get', 'cluster'], true]
  ],
  'paint': {
  'circle-color': '#8dd3c7',
  'circle-radius': 5
  }
});


map.addLayer({
  'id': 'powerplant_individual_outer',
  'type': 'circle',
  'source': 'powerplants',
  'filter': [
    'all',
    hydro,
    ['!=', ['get', 'cluster'], true]
  ],
  'paint': {
  'circle-color': 'rgba(0,0,0,0)',
  'circle-stroke-color': '#8dd3c7',
  'circle-stroke-width': 3,
  'circle-radius': 10
  }
});

最后,我们还可以为one category单个发电厂(非聚类点)创建一个层。

水力发电厂的聚类点和非聚类点 请注意,当您想一次显示一个类别时,这很有效。此示例将受益于一系列单选按钮,您可以切换这些按钮以显示不同类别的发电厂。 现在,如果我们想查看每个集群的发电厂比例怎么办?这才是clusteredProperties真正的威力大放异彩的地方。对于本教程的下一部分,我们将采用我们已构建的内容并对其进行修改以使用自定义 HTML 标记,这些标记将表示我们的集群和我们的燃料类型比率。

image.png

3 — HTML 标记

使用标记,您可以创建具有内置可视化效果的 HTML 集群 - 如圆环图。请注意,markers不同于circle layers。使用该addLayer()函数添加圆形图层并将类型设置为circle。markers通过Marker component,new mapboxgl.Marker()添加至其中任选地HTML元素,像这样:

new mapboxgl.Marker({element: SomeHTMLElement}).setLngLat(coordinates);

使用 HTML 标记进行聚类需要更多的手动同步。每次地图视图从平移、缩放、移动中发生变化时,集群的配置都会发生变化。这意味着每个聚类的点数会根据缩放级别等进行更新。因此,我们必须使用更新后的聚类数据更新我们的标记。

colors 颜色

在我们深入探讨之前,让我们先谈谈颜色,因为这很重要!对于我的大部分数据可视化工作,我总是参考这个漂亮的颜色指南。对于这张特定的地图,我使用Color Brewer 2.0 配色方案获取定性数据。对于本示例后面使用的case 表达式,我有 11 个类别 + 1 个回退。这是数组:

const colours = ['#8dd3c7','#ffffb3','#bebada','#fb8072','#80b1d3','#fdb462','#b3de69','#fccde5','#d9d9d9',' #bc80bd','#ccebc5','#ffed6f'];

Layers 图层

现在,让我们更新我们的图层以适应我们的新设置。我们将移除powerplant_cluster图层,powerplant_cluster_label因为集群将基于 HTML 并且我们将使用标记。我们还将修改powerplant_individudal/powerplant_individual_outer层,如下所示:

const colors = ['#8dd3c7','#ffffb3','#bebada','#fb8072','#80b1d3','#fdb462','#b3de69','#fccde5','#d9d9d9','#bc80bd','#ccebc5'];

// using d3 to create a consistent color scale
const colorScale = d3.scaleOrdinal()
  .domain(["hydro", "solar", "wind", "gas", "oil", "coal", "biomass", "waste", "nuclear", "geothermal", "others"])
  .range(colors)

map.addLayer({
  'id': 'powerplant_individual',
  'type': 'circle',
  'source': 'powerplants',
  'filter': ['!=', ['get', 'cluster'], true],
  'paint': {
    'circle-color': ['case',
      hydro, colorScale('hydro'),
      solar, colorScale('solar'),
      wind, colorScale('wind'),
      gas, colorScale('gas'),
      oil, colorScale('oil'),
      coal, colorScale('coal'),
      biomass, colorScale('biomass'),
      waste, colorScale('waste'),
      nuclear, colorScale('nuclear'),
      geothermal, colorScale('geothermal'),
      others, colorScale('others'), '#ffed6f'],
    'circle-radius': 5
  }
});

map.addLayer({
  'id': 'powerplant_individual_outer',
  'type': 'circle',
  'source': 'powerplants',
  'filter': ['!=', ['get', 'cluster'], true],
  'paint': {
    'circle-stroke-color': ['case',
      hydro, colorScale('hydro'),
      solar, colorScale('solar'),
      wind, colorScale('wind'),
      gas, colorScale('gas'),
      oil, colorScale('oil'),
      coal, colorScale('coal'),
      biomass, colorScale('biomass'),
      waste, colorScale('waste'),
      nuclear, colorScale('nuclear'),
      geothermal, colorScale('geothermal'),
      others, colorScale('others'), '#ffed6f'],
    'circle-stroke-width': 2,
    'circle-radius': 10,
    'circle-color': "rgba(0, 0, 0, 0)"
  }
});

我们刚刚做了什么?好吧,我们更新了我们的各个点,以便可以根据它们代表的发电厂类型对它们进行着色。使用案例表达式和决策表达式,我们将每个燃料类别与上面数组中定义的颜色进行匹配。

按类别着色的单个点

Marker updates 标记更新

我们已经设置了单点。现在,让我们创建一个函数来更新我们的自定义标记。每次我们的数据更改时都会触发此功能。正如我之前解释的,每次数据发生变化时,每个集群的点数可能会发生变化,因此,我们的标记必须更新。

自定义标记

首先,我们需要创建两个对象来跟踪屏幕上的标记和标记。这将有助于提高性能。我们不想保留不可见或代表过时集群的标记。

let markers = {};
let markersOnScreen = {};

接下来,我们可以编写我们的updateMarkers()函数,下面用注释分解:

const updateMarkers = () => {
  // keep track of new markers
  let newMarkers = {};
  // get the features whether or not they are visible (https://docs.mapbox.com/mapbox-gl-js/api/#map#queryrenderedfeatures)
  const features = map.querySourceFeatures('powerplants');
  // loop through each feature
  features.forEach((feature) => {
    const coordinates = feature.geometry.coordinates;
    // get our properties, which include our clustered properties
    const props = feature.properties;
    // continue only if the point is part of a cluster
    if (!props.cluster) {
      return;
    };
    // if yes, get the cluster_id
    const id = props.cluster_id;
    // create a marker object with the cluster_id as a key
    let marker = markers[id];
    // if that marker doesn't exist yet, create it
    if (!marker) {
      // create an html element (more on this later)
      const el = createDonutChart(props, totals);
      // create the marker object passing the html element and the coordinates
      marker = markers[id] = new mapboxgl.Marker({
        element: el
      }).setLngLat(coordinates);
    }
    
    // create an object in our newMarkers object with our current marker representing the current cluster
    newMarkers[id] = marker;
    
    // if the marker isn't already on screen then add it to the map
    if (!markersOnScreen[id]) {
      marker.addTo(map);
    }
  });
  
  // check if the marker with the cluster_id is already on the screen by iterating through our markersOnScreen object, which keeps track of that
  for (id in markersOnScreen) {
    // if there isn't a new marker with that id, then it's not visible, therefore remove it. 
    if (!newMarkers[id]) {
      markersOnScreen[id].remove();
    }
  }
  // otherwise, it is visible and we need to add it to our markersOnScreen object
    markersOnScreen = newMarkers;
};

所以这个函数处理所有标记的创建并跟踪哪些标记应该被删除或更新。我们现在需要编写createDonutChart()将返回标记的 HTML的函数。首先,让我们添加几个新变量:
let markers = {};
let markersOnScreen = {};
// add these
let point_counts = [];
let totals;
然后,我们需要跟踪我们的点数,以便稍后正确缩放我们的圆环图 SVG。
const getPointCount = (features) => {
  features.forEach(f => {
    if (f.properties.cluster) {
      point_counts.push(f.properties.point_count)
    }
  })
  return point_counts;
};

const updateMarkers = () => {
  let newMarkers = {};
  const features = map.querySourceFeatures('powerplants');
  // add this
  totals = getPointCount(features);
  // ....
};

接下来,我们可以开始使用 SVG 构建集群。以下是我们的集群的样子:

让我们创建一个带有以下参数的函数: 聚类属性, 点数数组 此函数将使用三个主要元素生成 HTML 圆环图: 弧线 一个内圈 文本 要使用 D3.js 创建 SVG,我们需要定义几个键值: 数据,以数组形式 SVG 大小 基于域和范围的尺度 半径

onst createDonutChart = (props, totals) => {
  // create a div element to hold our marker
  const div = document.createElement('div');
  
  // create our array with our data
  const data = [
    {type: 'hydro', count: props.hydro},
    {type: 'solar', count: props.solar},
    {type: 'wind', count: props.wind},
    {type: 'oil', count: props.oil},
    {type: 'gas', count: props.gas},
    {type: 'coal', count: props.coal},
    {type: 'biomass', count: props.biomass},
    {type: 'waste', count: props.waste},
    {type: 'nuclear', count: props.nuclear},
    {type: 'geothermal', count: props.geothermal},
    {type: 'others', count: props.others},
  ];
  
  // svg config
  const thickness = 10;
  
  // this sets the scale for our circle radius and this is why we need the totals. We need to set a mininum and a maximum to define the domain and the range. 
  const scale = d3.scaleLinear()
    .domain([d3.min(totals), d3.max(totals)])
    .range([500, d3.max(totals)])
  
  // calculate the radius
  const radius = Math.sqrt(scale(props.point_count));
  // calculate the radius of the smaller circle
  const circleRadius = radius - thickness;
  
  
  // create the svg
  const svg = d3.select(div)
    .append('svg')
    .attr('class', 'pie')
    .attr('width', radius * 2)
    .attr('height', radius * 2);
  
  // create a group to hold our arc paths and center
  const g = svg.append('g')
    attr('transform', `translate(${radius}, ${radius})`);

   
  // create an arc using the radius above
  const arc = d3.arc()
    .innerRadius(radius - thickness)
    .outerRadius(radius);
  
  // create the pie for the donut
  const pie = d3.pie()
    .value(d => d.count)
    .sort(null);
  
  // using the pie and the arc, create our path based on the data
  const path = g.selectAll('path')
    .data(pie(data.sort((x, y) => d3.ascending(y.count, x.count))))
    .enter()
    .append('path')
    .attr('d', arc)
    .attr('fill', (d) => colorScale(d.data.type))
  
  // create the center circle
  const circle = g.append('circle')
    .attr('r', circleRadius)
    .attr('fill', 'rgba(0, 0, 0, 0.7)')
    .attr('class', 'center-circle')
  
  // create the text
  const text = g
    .append("text")
    .attr("class", "total")
    .text(props.point_count_abbreviated)
    .attr('text-anchor', 'middle')
    .attr('dy', 5)
    .attr('fill', 'white')
 
  return div;
}

剩下要做的就是将地图连接到更新功能。每次我们移动地图时,我们的标记都会根据当前的聚类配置而改变。

map.on('data', (e) => {
  if (e.sourceId !== 'powerplants' || !e.isSourceLoaded) return;

  map.on('move', updateMarkers);
  map.on('moveend', updateMarkers);
  updateMarkers();
});

要完成可视化,您可以添加交互以显示每个集群中每种植物的确切比例。您还应该添加一个键来显示匹配的颜色/燃料类型对。您可以在此处查看最终版本和代码