上个世纪一个名叫小帅的男子,他特别喜欢和不同的小伙伴运动,比如打打篮球、游泳等。同时他也喜欢把他们之间的运动事件按照表格的形式记录下来,常常回味:
为了更直观展现它们的运动事件,我们用 G2 可视化了一下上面的数据,得到了如下的结果:
接下来我们就来看看如何实现这张图,源码在这里。
实现思路
我们先来看看这张图的实现思路。
可以发现,除去“浮华”的外表,这张图本质上就是一个折线图,只不过还添加了区间标注、emoji 文本以及图片数据标签。
可以把它们分别都想象成一个图层,然后一层层堆叠起来构成我们最后的图表。G2 的视图(View)就提供了堆叠图层(或者说标记 Mark)的功能。那我们接下来就一步步来看看,该如何实现。
代码实现
首先,我们封装一个工具方法,来简化代码:
function g2(options) {
const chart = new G2.Chart();
chart.options(options).render();
return chart.getContainer();
}
也声明一个对时间格式化的函数:
function format(date) {
const year = date.getFullYear();
const month = date.getMonth() + 1;
const wrapper = (d) => (d <= 9 ? "0" + d : d);
return `${year}${month == 1 ? "" : "." + wrapper(month)}`;
}
声明视图
首先我们声明一个视图,去指定数据、宽高、标题和比例尺等全局的信息。
g2({
type: "view",
// 布局信息
width: 900,
insetBottom: 20,
insetTop: 10,
height: 1300,
marginLeft: 30,
marginRight: 30,
// 按照时间排序
data: data.sort((a, b) => new Date(a.date) - new Date(b.date)),
scale: { y: { nice: true, range: [0, 1] } },
axis: { y: { grid: true, label: false, tick: false }, x: { title: "时间" } },
legend: { color: false },
interaction: { tooltip: false },
theme: { type: "dark" },
// 图表标题
title: {
title: "小帅和他的朋友们",
subtitle: "如有雷同,纯属巧合"
},
encode: {
x: (d) => new Date(d.date),
y: (_, i) => i + ""
},
children: [
// ...
]
})
这之后我们就需要向 children
里面添加标记了。
添加区域标注
我们先来画我们的背景的标注。这里使用 RangeX 标记,同时使用 d3.group 方法来简单处理一下数据。对应代码如下:
g2({
// ...
children: [
//...
{
type: "rangeX", // rangeX 标记
data: d3
.groups(data, (d) => d.name)
.map(([name, events]) => ({
name,
start: events[0].date,
end: events[events.length - 1].date
})),
encode: {
x: "start",
x1: "end",
color: "name"
},
style: { fillOpacity: 0.5 },
labels: [
{ // 每个人的名字和有关联的事件
text: (d) => `${d.name}(${format(d.start)}~${format(d.end)})`,
position: "top",
dy: -20,
dx: (_, i) => (i === 2 ? 30 : 0)
}
]
}
// ...
]
})
绘制折线图和 Emoji
这之后我们就可以绘制折线图以及 Emoji 了。其中折线图我么用 Line Mark 和 Point Mark 共同绘制,然后用 Text Mark 去绘制 Emoji,具体代码如下:
g2({
// ...
children: [
{
type: "line", // Line Mark
style: { shape: "smooth", stroke: "orange", strokeWidth: 3 }
},
{
type: "point", // Point Mark
encode: { size: 12, color: "#fff" },
style: {
shape: "point",
stroke: "orange",
strokeWidth: 3,
fillOpacity: 1
}
},
{
type: "text", // Text Mark
encode: { text: "type" },
scale: {
text: {
// 通过比例尺将 type 映射为对应 Emoji
type: "ordinal",
domain: [0, 1, 2, 3, 4, 5],
range: ["🏊♀️", "🏀", "🏸️", "🏓️", "🥏", "🏈"],
independent: true
}
},
style: {
textAlign: "center",
textBaseline: "middle",
dx: 2,
fontSize: 15
}
},
]
})
绘制事件和图片
最后我们来绘制事件文本和图片,这里文本用 Text Mark,同时图片用 HTML 标签:
g2({
// ...
children: [
// ...
{ // Text Mark
// 展示事件
type: "text",
tooltip: false,
encode: { text: "events" },
labels: [
{
text: (d) => format(d.date), // 展示日期
position: "right",
textAlign: (_, i) => (i < data.length - 2 ? "start" : "end"),
dx: (_, i) => (i < data.length - 2 ? 0 : -20)
},
{
text: "image", // 展示图片
render: (_, d, i) => {
const { image } = d;
const dx = i === 0 ? 300 : i < data.length - 2 ? -180 : -120;
return `<img
src="${image}"
height="50"
style="transform: translate(${dx}%, -20%);border-radius:10px"
/>`;
},
style: { opacity: 1 },
position: "top-left"
}
],
style: {
fill: "#eee",
fontSize: 14,
fontWeight: "bold",
dx: (_, i) => (i < data.length - 2 ? 30 : -70),
textAlign: (_, i) => (i < data.length - 2 ? "start" : "end")
}
}
]
})
完整代码
面是完整的代码,如果觉得不错的话就去给 G2 点个 star 吧!
g2({
type: "view",
width: 900,
insetBottom: 20,
insetTop: 10,
height: 1300,
marginLeft: 30,
marginRight: 30,
data: data.sort((a, b) => new Date(a.date) - new Date(b.date)),
scale: { y: { nice: true, range: [0, 1] } },
axis: { y: { grid: true, label: false, tick: false }, x: { title: "时间" } },
legend: { color: false },
interaction: { tooltip: false },
theme: { type: "dark" },
title: {
title: "小帅和他的朋友们",
subtitle: "如有雷同,纯属巧合"
},
encode: {
x: (d) => new Date(d.date),
y: (_, i) => i + ""
},
children: [
{
type: "rangeX",
data: d3
.groups(data, (d) => d.name)
.map(([name, events]) => ({
name,
start: events[0].date,
end: events[events.length - 1].date
})),
encode: {
x: "start",
x1: "end",
color: "name"
},
style: { fillOpacity: 0.5 },
labels: [
{
text: (d) => `${d.name}(${format(d.start)}~${format(d.end)})`,
position: "top",
dy: -20,
dx: (_, i) => (i === 2 ? 30 : 0)
}
]
},
{
type: "line",
style: { shape: "smooth", stroke: "orange", strokeWidth: 3 }
},
{
type: "point",
encode: { size: 12, color: "#fff" },
style: {
shape: "point",
stroke: "orange",
strokeWidth: 3,
fillOpacity: 1
}
},
{
type: "text",
encode: { text: "type" },
scale: {
text: {
type: "ordinal",
domain: [0, 1, 2, 3, 4, 5],
range: ["🏊♀️", "🏀", "🏸️", "🏓️", "🥏", "🏈"],
independent: true
}
},
style: {
textAlign: "center",
textBaseline: "middle",
dx: 2,
fontSize: 15
}
},
{
type: "text",
tooltip: false,
encode: { text: "events" },
labels: [
{
text: (d) => format(d.date),
position: "right",
textAlign: (_, i) => (i < data.length - 2 ? "start" : "end"),
dx: (_, i) => (i < data.length - 2 ? 0 : -20)
},
{
text: "image",
render: (_, d, i) => {
const { image } = d;
const dx = i === 0 ? 300 : i < data.length - 2 ? -180 : -120;
return `<img
src="${image}"
height="50"
style="transform: translate(${dx}%, -20%);border-radius:10px"
/>`;
},
style: { opacity: 1 },
position: "top-left"
}
],
style: {
fill: "#eee",
fontSize: 14,
fontWeight: "bold",
dx: (_, i) => (i < data.length - 2 ? 30 : -70),
textAlign: (_, i) => (i < data.length - 2 ? "start" : "end")
}
}
]
})