最近巴以冲突是人们关注的热点,这里我们就使用 G2 可视化一下其时间线,最后的效果如下图所示:
那接下来就来看看如何用 G2 来绘制这张图,看完你将有如下的收获:
- G2 如何设置图表的 title
- G2 如何设置图表的背景颜色
- G2 中 fold 数据处理的使用方法
- G2 如何自定义映射颜色
- G2 如何绘制数据驱动的 label
- G2 如何绘制 html label
- G2 如何自定义图例的文本
可交互的版本可以通过如下渠道看:
接下来我们就开始可视化。注意,这里更多是从技术视角介绍这张图的可视化思路,更多相关介绍请看新闻稿。
准备
首先我们了解一下数据,该数据是一份表格数据,每一个数据行包含时间、事件以及当前两方的伤亡情况。
我们简单封装一个工具函数来简化可视化声明,这里用到了 G2 5.0 新的 Spec API:
function g2(options) {
const chart = new G2.Chart();
chart.options(options);
chart.render();
return chart.getContainer();
}
思路
在开始写代码前,我们简单来梳理一下可视化的思路。上面这张图看起来复杂,其实主体本质上就是一个柱形图 + 面积图 + 数据标签,而我们可以通过类似“叠图层”的方式,将它们组合成一个图表,最后再优化一下样式即可。
下面就到了写代码时间。
视图
我们所有的内容都是放在如下的一个视图里的,然后通过 children 配置去添加不同的标记。
g2({
type: "view",
width: 800, // 一些布局信息
height: 1200,
insetTop: 15,
insetRight: 10,
marginRight: 150,
data, // 可视化数据
title: { // 图表标题
title: "巴以冲突时间线",
subtitle: "数据来源:新华网|统计时间:2023.10.17",
titleFontWeight: "bolder",
titleFontSize: 20
},
coordinate: { transform: [{ type: "transpose" }] }, // 向 y 方向绘制
encode: { x: "date", y: (d) => d.death + d.injured }, // 一些共用的编码信息
axis: false, // 隐藏坐标轴
style: { viewFill: "rgb(253, 247, 239)" }, // 设置背景颜色
children: [ // 添加标记,具体的配置见下面
{ type: "area", /* ... */ },
{ type: "interval", /* ... */ },
{
type: "line", /* ... */
labels: [{/* ... */}, {/* ... */}]
},
{ type: "point", /* ... */ },
{ type: "text", /* ... */ }
]
})
面积图
我们首先添加一个 Area Mark 绘制一个面积图,来作为我们的背景:
g2({
// ...
children: [
{
type: "area",
tooltip: false,
style: { fillOpacity: 0.1, fill: "red" }
},
// ...
]
})
条形图
接下来我们使用 Interval Mark 绘制图中的条形图:
这里我们首先需要使用 fold 数据处理方法将数据展开,使得每一个条对应一条数据,然后自定义颜色、图例文本和独立 x 通道的比例尺。
g2({
// ...
children: [
// ...
{
type: "interval",
scale: {
x: { key: "interval", paddingInner: 0.15 }, // 独立 x 通道比例尺
color: {
range: [ // 自定义颜色
"rgba(0, 0, 0, 0.8)",
"rgba(0, 0, 0, 0.5)",
"rgba(236, 61, 11, 0.8)",
"rgba(236, 61, 11, 0.5)"
]
}
},
legend: {
color: {
position: "bottom",
labelFormatter: (d) => {
const labelByValue = { // 自定义图例文本
israelDeath: "以色列死亡人数",
hamasDeath: "哈马斯死亡人数",
israeInjured: "以色列受伤人数",
hamasInjured: "哈马斯受伤人数"
};
return labelByValue[d];
}
}
},
data: {
transform: [
{ // 展开数据
type: "fold",
fields: [
"israelDeath",
"hamasDeath",
"israeInjured",
"hamasInjured"
],
key: "type",
value: "value"
}
]
},
// 根据展开数据指定通道
encode: { y: "value", color: "type", series: "type" }
},
// ...
]
})
线和数据标签
这之后我们绘制那根代表伤亡总人数的趋势线,以及为时间和事件添加标注文本:
这里我们需要添加一个 Line Mark,以及通过其 labels 属性,给它添加两个数据标签。第一个用来展示时间,第一个用来展示事件。需要注意的是,因为事件文本需要突出伤亡的具体信息(数字),对文本的展示和布局有较高的要求,所以这里我们使用 html 绘制:
g2({
// ...
children: [
// ...
{
type: "line", // 绘制趋势线
style: {
stroke: "black",
shape: "smooth",
strokeWidth: 10,
lineCap: "round"
},
tooltip: false,
labels: [
{ // 展示日期,属性都是数据驱动的
text: (d) => {
const N = d.date.split("-");
return `${N[1]}.${N[2]}`;
},
fontFamily: "Alibaba Sans 102",
letterSpacing: -1,
fontSize: 32,
fontWeight: "bold",
dy: (d) => (+d.date.split("-").pop() === 17 ? -15 : 0),
dx: (d) => (+d.date.split("-").pop() < 13 ? 30 : -30),
textAnchor: (d) => (+d.date.split("-").pop() < 13 ? "start" : "end"),
fillOpacity: 1
},
{ // 展示事件信息
text: (d) => d.event,
// render 函数返回的 string 会作为 innerHTML 渲染到 DOM 树中
render: (text, d) => {
const date = +d.date.split("-").pop();
const right = date < 13;
const dx = right ? 65 : -380;
const dy = right ? 15 : date === 17 ? -100 : -90;
const span = (text) => {
return `<span style="color:red;font-size:18px;font-weight:bold">${text}</span>`;
};
const textHtml = text
.replace(d.injured, span(d.injured))
.replace(d.death, span(d.death));
const width = date === 12 ? 250 : 300;
return `<div style="
width: ${width}px;
font-size: 12px;
transform: translate(${dx}px, ${dy}px);
padding: 5px;
color: black;
font-family: 'Alibaba Sans 102'"
>${textHtml}</div>`;
}
}
]
},
// ...
]
})
圆圈和 Emoji
最后我们来绘制背景的圆圈和 Emoji 表情:
g2({
// ...
children: [
// ...
{ // 背景圆
type: "point",
encode: { size: 14 },
style: {
shape: "point",
fill: "white",
stroke: "red",
strokeWidth: 3,
fillOpacity: 1
},
tooltip: false
},
{ // 用 text 标记去绘制 Emoji
type: "text",
encode: { text: "date" },
scale: {
text: { // 映射文本内容
type: "ordinal",
range: ["🚀", "🔥", "⚡️", "🏠", "✈️", "👊", "👊", "🙅"]
}
},
style: {
textAlign: "center",
textBaseline: "middle",
dx: 2,
fontSize: 15
}
}
// ...
]
})
小结
这篇文章简单教大家如何用 G2 去绘制比较复杂的可视化,同时也给大家展示了 G2 在保证一定易用性的基础上,给用户的所提供的灵活性。
最后,愿天下没有战争。
附录
最后附上 Live Demo 的地址和完整代码。
g2({
type: "view",
width: 800,
height: 1200,
insetTop: 15,
insetRight: 10,
marginRight: 150,
data,
title: {
title: "巴以冲突时间线",
subtitle: "数据来源:新华网|统计时间:2023.10.17",
titleFontWeight: "bolder",
titleFontSize: 20
},
coordinate: { transform: [{ type: "transpose" }] },
encode: { x: "date", y: (d) => d.death + d.injured },
axis: false,
style: { viewFill: "rgb(253, 247, 239)" },
children: [
{
type: "area",
tooltip: false,
style: { fillOpacity: 0.1, fill: "red" }
},
{
type: "interval",
scale: {
x: { key: "interval", paddingInner: 0.15 },
color: {
range: [
"rgba(0, 0, 0, 0.8)",
"rgba(0, 0, 0, 0.5)",
"rgba(236, 61, 11, 0.8)",
"rgba(236, 61, 11, 0.5)"
]
}
},
legend: {
color: {
position: "bottom",
labelFormatter: (d) => {
const labelByValue = {
israelDeath: "以色列死亡人数",
hamasDeath: "哈马斯死亡人数",
israeInjured: "以色列受伤人数",
hamasInjured: "哈马斯受伤人数"
};
return labelByValue[d];
}
}
},
data: {
transform: [
{
type: "fold",
fields: [
"israelDeath",
"hamasDeath",
"israeInjured",
"hamasInjured"
],
key: "type",
value: "value"
}
]
},
encode: { y: "value", color: "type", series: "type" }
},
{
type: "line",
style: {
stroke: "black",
shape: "smooth",
strokeWidth: 10,
lineCap: "round"
},
tooltip: false,
labels: [
{
text: (d) => {
const N = d.date.split("-");
return `${N[1]}.${N[2]}`;
},
fontFamily: "Alibaba Sans 102",
letterSpacing: -1,
fontSize: 32,
fontWeight: "bold",
dy: (d) => (+d.date.split("-").pop() === 17 ? -15 : 0),
dx: (d) => (+d.date.split("-").pop() < 13 ? 30 : -30),
textAnchor: (d) => (+d.date.split("-").pop() < 13 ? "start" : "end"),
fillOpacity: 1
},
{
text: (d) => d.event,
render: (text, d) => {
const date = +d.date.split("-").pop();
const right = date < 13;
const dx = right ? 65 : -380;
const dy = right ? 15 : date === 17 ? -100 : -90;
const span = (text) => {
return `<span style="color:red;font-size:18px;font-weight:bold">${text}</span>`;
};
const textHtml = text
.replace(d.injured, span(d.injured))
.replace(d.death, span(d.death));
const width = date === 12 ? 250 : 300;
return `<div style="
width: ${width}px;
font-size: 12px;
transform: translate(${dx}px, ${dy}px);
padding: 5px;
color: black;
font-family: 'Alibaba Sans 102'"
>${textHtml}</div>`;
}
}
]
},
{
type: "point",
encode: { size: 14 },
style: {
shape: "point",
fill: "white",
stroke: "red",
strokeWidth: 3,
fillOpacity: 1
},
tooltip: false
},
{
type: "text",
encode: { text: "date" },
scale: {
text: {
type: "ordinal",
range: ["🚀", "🔥", "⚡️", "🏠", "✈️", "👊", "👊", "🙅"]
}
},
style: {
textAlign: "center",
textBaseline: "middle",
dx: 2,
fontSize: 15
}
}
]
})