Skip to content
On this page

分析-西方哲学中心的“迁徙之旅”


本章我们将以地理视角看哲学家,学会使用地理可视化完成 “苏菲的世界中,哲学中心如何变化?” 这个任务。

任务分析

我们要分析哲学家的地理位置分布,首先需要找到与“地理”相关的信息,这里刚好有哲学家的所在国家名,我们以哲学家的所在国家作为地理数据,就可以分析“哲学家所在国家分布情况”,然后我们可以利用哲学家的出生日期来分析“哲学中心如何变化”。

大致需要以下几步完成任务

  • 拾取全世界各个国家的地理数据
  • 将国家名与拾取到的国家地理进行关联
  • 使用地理数据可视化库 L7 & L7Plot 将数据给可视化出来
  • 获取数据洞察

下面按照这几个步骤,实现一个哲学家所在国的区域分布图与哲学中心变化图,并从中获得数据洞察。

可视分析

获取数据

我们要分析的数据”哲学家的所在国家”,数据格式如下:

namepointslifespancountry
泰利斯"水是万物之源。"[-624, -546]希腊
安纳克西曼德"形成万物的物质不可能是一定是这些已经被创造出来的物质。"[-610, -546]希腊
亚里士多德"现实生活中最高层次的事物不是那些我们用理性来探索的事物,而是我们用感官察觉的事物。","事物是由本身的形式与质料和谐一致的事物所构成的,质料总是致力于实现一种内在的可能性。"[-384, -322]雅典
苏格拉底"我只知道一件事情,就是我一无所知。", "明辨是非的能力就存在于人的理性中,而不存在于社会中。"[-470, -399]雅典
普罗汀"宇宙间万事万物都是一体的,因为上帝存在于万事万物之中。"[-270, -205]意大利

接下来拾取全世界各个国家的地理数据,这里使用地理数据共享开源组织 openstreetmap 的数据,为了方便使用采用 GeoJson 格式的地理数据,数据格式如下:

json
{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {
        "type": "Polygon",
        "coordinates": [ // ... ]
      },
      "properties": {
        "name": "希腊",
        "centroid": [22.989178297364294,39.04355317366236]
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Polygon",
        "coordinates": [ // ... ]
      },
      "properties": {
        "name": "意大利",
        "centroid": [12.075174042231398,42.78877076169903]
      }
    },
  ]
}

Sparrow 绘制气泡地图

有了数据之后,我们首先尝试用 Sparrow 去绘制一个气泡地图。如果在书中出现了该国家的哲学家,那么这个国家会有一个气泡,出现的哲学家数量越多,这个地方的气泡越大。

同样 Sparrow 本身不具备计算地图路径的能力,所以这个地方我们借助 d3-geo 去完成我们的可视化。

image.png

javascript
(async () => {
  //@see https://observablehq.com/@d3/world-airports?collection=@d3/d3-geo

  // 获取数据
  const URLS = [
    "https://gw.alipayobjects.com/os/bmw-prod/a51018d2-69ef-4e6b-8095-5d4f5815166e.json",
    "https://gw.alipayobjects.com/os/bmw-prod/d345d2d7-a35d-4d27-af92-4982b3e6b213.json",
    "https://gw.alipayobjects.com/os/bmw-prod/1070dc04-329e-4655-95b0-f1dc094206b1.json",
  ];
  const [world, people, country] = await Promise.all(
    URLS.map(async (url) => {
      const response = await fetch(url);
      return await response.json();
    })
  );

  const projection = d3.geoNaturalEarth1();

  // 确定视图大小
  const outline = { type: "Sphere" };
  const width = 800;
  const height = (() => {
    const [[x0, y0], [x1, y1]] = d3
      .geoPath(projection.fitWidth(width, outline))
      .bounds(outline);
    const dy = Math.ceil(y1 - y0),
      l = Math.min(Math.ceil(x1 - x0), dy);
    projection.scale((projection.scale() * (l - 1)) / l).precision(0.2);
    return dy;
  })();

  // compute paths data
  const path = d3.geoPath(projection);
  const land = topojson.feature(world, world.objects.land);
  const graticule = d3.geoGraticule10();
  const paths = [
    { d: path(land), fill: "#ddd", stroke: "none" },
    { d: path(graticule), fill: "none", stroke: "#ddd" },
    { d: path(outline), fill: "none", stroke: "black" },
  ];

  // 计算气泡数据
  const countryCentroid = new Map(
    country.features
      .filter((d) => d.properties.name)
      .map((d) => [d.properties.name, d.properties.centroid])
  );
  const countryName = (d) => (d === "雅典" ? "希腊" : d);
  const countries = Array.from(
    d3.group(people, (d) => d.country),
    ([country, people]) => {
      const [x, y] = projection(countryCentroid.get(countryName(country)));
      return {
        country,
        count: people.length,
        x,
        y,
      };
    }
  );

  // 绘制
  return sp.plot({
    type: "layer",
    width,
    height,
    children: [
      {
        type: "path",
        data: paths,
        scales: {
          color: { type: "identity" },
        },
        guides: { color: { display: false } },
        encodings: {
          d: "d",
          fill: "fill",
          stroke: "stroke",
        },
      },
      {
        type: "point",
        guides: { x: { display: false }, y: { display: false } },
        scales: {
          y: { range: [0, 1], domain: [0, height] },
          x: { domain: [0, width] },
          r: { range: [3, 6] },
        },
        paddingLeft: 0,
        paddingTop: 0,
        paddingBottom: 0,
        paddingRight: 0,
        data: countries,
        encodings: {
          x: "x",
          y: "y",
          r: "count",
          fill: "steelblue",
          stroke: "steelblue",
        },
      },
    ],
  });
})();

虽然 Sparrow 已经把一个简单的地图绘制完成了,但是可以发现过程还是比较繁琐的,为了解决这个问题,我们来看看 AntV 的 L7 是如何绘制地图的。

L7 绘制地图

L7 同样需要把地理数据给关联上,通过国家名称字段将我们拾取的地理数据关联,最后将数据处为理常用的 GeoJson 格式,需要注意的是,数据中的“雅典”是“希腊”的首都。

javascript
const MappingName = { 雅典: "希腊" };
const joinData = (philosophers, geoData) => {
  const philosopherCountryMap = philosophers.reduce(
    (countryMap, philosopher) => {
      const { country } = philosopher;
      const mappingCountry = MappingName[country] || country;
      if (countryMap[mappingCountry]) {
        countryMap[mappingCountry].push(philosopher);
      } else {
        countryMap[mappingCountry] = [philosopher];
      }
      return countryMap;
    },
    {}
  );
  const features = geoData.features.map((feature) => {
    const countryName = feature.properties.name;
    const philosophers = philosopherCountryMap[countryName] || [];
    const philosopherSum = philosophers.length;
    const currentFeature = Object.assign({}, feature, {
      properties: {
        ...feature.properties,
        philosophers,
        philosopherSum,
      },
    });

    return currentFeature;
  });
  const geoPhilosophers = Object.assign({}, geoData, { features });

  return geoPhilosophers;
};

我们有了数据,就可以开始利用数据做可视分析了。根据数据中各个国家的哲学家数量,我们先来实现一个气泡图,气泡的地理位置采用国家的行政中心, 气泡大小与各个国家的哲学家数量正相关,这里可视化库我们使用 L7

javascript
import { Scene, Mapbox, PointLayer } from "@antv/l7";
import countrys from "./countrys.json";
import philosophers from "./philosophers.json";

// 1. 创建场景,并创建 Mapbox 作为地图的底图
const scene = new Scene({
  id: container,
  map: new Mapbox({
    style: "light",
    pitch: 0,
    zoom: 1,
    center: [43.3573729, 9.5221543],
  }),
});

// 2. 生成地理数据
const geoPhilosophers = joinData(philosophers, countrys);
const geoPositions = geoPhilosophers.features
  .map(({ properties }) => properties)
  .filter(({ philosopherSum }) => philosopherSum);

// 3. 创建气泡图层,并将气泡大小映射到数据字段上
const pointLayer = new PointLayer({
  autoFit: true,
})
  .source(geoPositions, {
    parser: { type: "json", coordinates: "centroid" },
  })
  .shape("circle")
  .size("philosopherSum", [20, 60])
  .color("#204CCF")
  .style({ opacity: 0.6 });

// 4. 创建文本标注图层
const labelLayer = new PointLayer({})
  .source(geoPositions, {
    parser: { type: "json", coordinates: "centroid" },
  })
  .shape("name", "text")
  .size(10)
  .color("#000")
  .style({ opacity: 0.8, fontSize: 10, stroke: "#fff", strokeWidth: 2 });

// 5. 将图层挂载到场景上
scene.addLayer(pointLayer);
scene.addLayer(labelLayer);

绘制出来的效果见下图。从图中可以看出,利用气泡的方式只能看国家点的分布,接下来我们来做国家区域分布的图,利用区域的颜色映射哲学家的数量,这里使用 L7Plot 来完成一个区域分布图,关于 L7 与 L7Plot 的关系见后文“延伸”部分。

javascript
import { Area } from "@antv/l7plot";
import countrys from "./countrys.json";
import philosophers from "./philosophers.json";

// 1. 生成地理数据
const geoPhilosophers = joinData(philosophers, countrys);

// 2. 创建图表
const areaMap = new Area(container, {
  map: {
    type: "mapbox",
    style: "light",
    pitch: 0,
    zoom: 1,
    center: [12.075174042231398, 42.78877076169903],
  },
  source: {
    data: geoPhilosophers,
    parser: { type: "geojson" },
  },
  autoFit: true,
  color: {
    field: "philosopherSum",
    value: ["#B8E1FF", "#7DAAFF", "#3D76DD", "#0047A5"],
    scale: { type: "quantize" },
  },
  style: {
    opacity: 0.8,
    stroke: "#F2F7F7",
    lineType: "dash",
    lineDash: [1, 10],
    lineWidth: 0.6,
    lineOpacity: 0.8,
  },
  label: {
    field: "name",
    style: { fill: "#000", fontSize: 10, stroke: "#fff", strokeWidth: 2 },
  },
  tooltip: { items: ["name", "adcode"] },
  zoom: { position: "bottomright" },
  legend: { position: "bottomleft" },
});

绘制出来的效果见下图。接下来我们以哲学家的出生日期为时间线,对相同生平区间的哲学家进行分组,来做一个哲学区域变化图,以时间递增顺序渲染哲学区域。

javascript
import { Area } from "@antv/l7plot";
import countrys from "./countrys.json";
import philosophers from "./philosophers.json";

// 1. 以时间顺序对相同生平区间的哲学家进行分组
const philosopherGroups = philosophers.reduce((groups, philosopher) => {
  if (groups.length === 0) return [[philosopher]];
  const lastGroup = groups[groups.length - 1];
  const lastPhilosopherLifeEndTime =
    lastGroup[lastGroup.length - 1].lifespan[1];
  const currentPhilosopherLifeStartTime = philosopher.lifespan[0];
  if (lastPhilosopherLifeEndTime >= currentPhilosopherLifeStartTime) {
    lastGroup.push(philosopher);
  } else {
    groups.push([philosopher]);
  }
  return groups;
}, []);

// 2. 创建图表
const areaMap = new Area(container, {
  map: {
    type: "mapbox",
    style: "light",
    center: [20.3573729, 40.5221543],
    zoom: 3,
    pitch: 0,
  },
  source: {
    data: { type: "FeatureCollection", features: [] },
    parser: { type: "geojson" },
  },
  autoFit: false,
  color: {
    field: "philosopherSum",
    value: ["#B8E1FF", "#7DAAFF", "#3D76DD", "#0047A5"],
    scale: { type: "quantize" },
  },
  style: {
    opacity: 0.8,
    stroke: "#F2F7F7",
    lineType: "dash",
    lineDash: [1, 10],
    lineWidth: 0.6,
    lineOpacity: 0.8,
  },
  label: {
    field: "name",
    style: { fill: "#000", fontSize: 10, stroke: "#fff", strokeWidth: 2 },
  },
  state: { active: true, select: false },
  tooltip: {
    items: [
      { field: "name" },
      { field: "philosopherSum" },
      {
        field: "philosophers",
        customValue: (value) =>
          value
            .map(
              (item) =>
                `name: ${item.name}, lifespan: ${item.lifespan.join("~")}`
            )
            .join(";"),
      },
    ],
  },
  zoom: { position: "bottomright" },
  legend: { position: "bottomleft" },
});

// 3. 按时间递增顺序渲染更新区域变化图
let currentGroupIndex = 0;
chinaMap.on("loaded", () => {
  const geoPhilosophers = joinData(
    philosopherGroups[currentGroupIndex],
    countrys
  );
  chinaMap.changeData(geoPhilosophers);
});
chinaMap.on("change-data", () => {
  currentGroupIndex =
    currentGroupIndex === philosopherGroups.length - 1
      ? 0
      : ++currentGroupIndex;
  const geoPhilosophers = joinData(
    philosopherGroups[currentGroupIndex],
    countrys
  );
  setTimeout(() => {
    chinaMap.changeData(geoPhilosophers);
  }, 1500);
});

最后绘制出来的效果如下图所示,代码地址 visualize-sophie-world

各个国家的哲学家数量气泡图各个国家的哲学家数量区域分布以时间顺序哲学区域分布变化

数据洞察

从中我们可以获取一些有效信息和洞察:

  • 哲学家主要集中在欧洲这些国家
  • 哲学家最多的国家,希腊哲学家最多,意大利次之
  • 地中海沿岸国家,哲学家比较集中
  • 随着时间的变化,哲学中心从地中海北部迁移至北海

延伸

地理数据可视化库

开源地理数据可视化库及相关工具类

2/2.5D

3D

L7、L7Plot 关系

L7

基于 WebGL 的地理空间数据可视化引擎,以图形符号学为理论基础,将抽象复杂的空间数据转化成 2D、3D 符号,通过颜色、大小、体积、纹理等视觉变量实现丰富的可视化表达。在 API 使用语法上跟 G2 一样基于图形语法。 与 G2 区别: L7 专注于地理空间数据可视分析,G2 专注于统计图表。它的取名中的 L 代表 Location,7 代表世界七大洲。

L7Plot

L7Plot 基于 L7 实现的开箱即用地理空间数据可视化图表库,与 L7 区别:

  • L7 基于图形符号学为理论,以图形语法 API 方式使用,概念较多,实现简单的地理可视化业务需要不少代码量。
  • L7Plot 专注于专注于地理可视化图表;以声明配置式的方式,降低用户使用成本;以常见地理图表分类的方式,降低用户选择成本;内部集成全国行政区域数据,降低用户使用地理数据心智。
  • L7Plot L7Plot 专注于地理数据可视化展示,不会涉及数据编辑能力。

小结

这一章我们完成了:“苏菲的世界中,哲学中心如何变化?”这一个任务。

到此为止,我们所有的开发和分析任务就都结束了,那么下一章将是我们的最后一章:看看通过本小册子我们学到了什么,还有什么可以深入学习的东西。