本文来自 VisActor 开源贡献者:MengXi
github:github.com/mengxi-ream
设计说明
我们这次制作了一个单元可视化的模板,下面是一个简单的例子例子:
单元可视化中,每个单元表示一个数据点,它可以是一个人,一组人,或者一个物品等等。下面是一个非常著名的例子:
这个模板的应用范围很广,可以用于数据分析、科学研究、教育培训等多个领域。它为用户提供了一个强大而灵活的工具,帮助他们更好地理解和展示复杂的数量关系。
模板中“幕”的定义
就像电影中那样,一个单元可视化模板,可以包含多个幕,每一幕都支持标题和单元组件,来讲述一个事实或者故事。
模板中的组件
1. 标题
标题支持富文本的定义,可以定义标题的样式,颜色,大小等属性。并且标题的长宽都是可以自定义的。随着时间的变化,可以在不同幕出现不同的标题,和下面的单元组件进行组合叙事,并且支持动画效果。
2. 单元组件
单元组件是整个模板的核心,总的来说,它就是把一定数量的单元点,按照制定的规则,进行排列,并且可以自定义每个单元点的样式,颜色,大小等属性。并且支持动画效果。下面是我们要实现的细节:
- 单元点必须充满指定长方形区域,这个区域可以自定义 padding
- 支持单元点横向或者竖向排布
- 单元点可以自定义形状,可以是圆形,椭圆形,矩形,多边形等,但是形状的长宽比是固定的,否则无法通过数学快速计算排布
- 单元点可以自定义颜色,以及每个单元点之间横向和竖向的间距
- 不同幕之间的转化支持动画效果,包括错位时间消失(像上面例子中那样),或者淡入淡出等效果
模板输入
下面是关于模板输入的定义,对应上面提到的功能设计
export interface Input {
layout?: {
width?: number;
height?: number;
title?: {
height?: number;
backgroundColor?: string;
style?: IEditorTextGraphicAttribute;
padding?: {
left?: number;
right?: number;
top?: number;
bottom?: number;
};
};
viz?: {
backgroundColor?: string;
direction?: 'horizontal' | 'vertical';
padding?: {
left?: number;
right?: number;
top?: number;
bottom?: number;
};
};
};
unit?: {
gap?: [number, number];
aspect?: number;
defaultStyle?: ISymbolGraphicAttribute | ((index: number) => ISymbolGraphicAttribute);
};
data: Record<string, any>[];
scenes: {
title: ITextGraphicAttribute[];
sceneDuration?: number;
animationDuration?: number;
nodes: QueryNode[];
}[];
}
export interface QueryNode {
query?: (datum: any) => boolean;
style?: ISymbolGraphicAttribute;
children?: QueryNode[];
}
使用说明
用户只用传入 json 格式的 spec,就可以生成视频。根据上面对 spec 的定义,内部已经给出了一些默认的配置,所以如下的配置用户不需要自定义的话不用给出:
export const defaultInput = {
layout: {
width: 1920,
height: 1080,
title: {
height: 250,
backgroundColor: '#ffffff',
style: {
fontSize: 40,
fontWeight: 200,
textAlign: 'center',
fill: 'black',
wordBreak: 'break-word'
},
padding: {
left: 50,
right: 50,
top: 50,
bottom: 0
}
},
viz: {
backgroundColor: '#ffffff',
direction: 'horizontal',
padding: {
left: 50,
right: 50,
top: 50,
bottom: 50
}
}
},
unit: {
gap: [0.5, 0.5],
aspect: 1,
defaultStyle: {
symbolType: 'circle',
fill: '#ffffff'
}
},
};
用户主要给出的是 data 和 scene 的 spec,比如要生成下面这段视频 demo,对应的 spec 如下。
export const userInput: Input = {
layout: {
width: 1550,
height: 800,
viz: {
padding: {
top: 0
}
},
title: {
style: {
fontSize: 36
},
height: 150
}
},
unit: {
gap: [0.2, 0.2],
defaultStyle: {
fill: '#222222'
}
},
data: (data as Record<string, any>[]).filter(record => record.year === 2014),
scenes: [
{
title: [
{
text: 'More than '
},
{
text: '33,000',
fontWeight: 'bold'
},
{
text: ' people are fatally shot in the U.S. each year. This is for test. This is f test. This is for test'
}
],
nodes: [
{
style: {
fill: '#dedede'
}
}
]
},
{
title: [
{
text: 'Nearly two-third of gun deaths are'
},
{
text: 'suicides',
fontWeight: 'bold'
},
{
text: '.'
}
],
nodes: [
{
query: datum => datum.intent === 'Suicide',
style: {
fill: '#e3662e'
}
}
]
},
{
sceneDuration: 3000,
animationDuration: 500,
title: [
{
text: 'More than 85 percent of suicide victims are '
},
{
text: 'male',
fontWeight: 'bold'
},
{
text: '...'
}
],
nodes: [
{
query: datum => datum.intent === 'Suicide',
style: {
fill: '#f4cfbb'
},
children: [
{
query: datum => datum.sex === 'M',
style: {
fill: '#e3662e'
}
}
]
}
]
},
{
sceneDuration: 3000,
animationDuration: 500,
title: [
{
text: '... and more than half of all suicides are '
},
{
text: 'men age 45 older',
fontWeight: 'bold'
}
],
nodes: [
{
query: datum => datum.intent === 'Suicide',
style: {
fill: '#f4cfbb'
},
children: [
{
query: datum => datum.sex === 'M' && datum.age >= 45,
style: {
fill: '#e3662e'
}
}
]
}
]
},
{
title: [
{
text: 'Another third of all gun deaths — about 12,000 in total each year — are '
},
{
text: 'homicides',
fontWeight: 'bold'
},
{
text: '.'
}
],
nodes: [
{
query: datum => datum.intent !== 'Homicide',
style: {
fill: '#dedede'
}
},
{
query: datum => datum.intent === 'Homicide',
style: {
fill: '#5D76A3'
}
}
]
},
{
title: [
{
text: 'More than half of homicide victims are '
},
{
text: 'young men',
fontWeight: 'bold'
},
{
text: '...'
}
],
nodes: [
{
query: datum => datum.intent === 'Homicide',
style: {
fill: '#C6CEDF'
},
children: [
{
query: datum => datum.sex === 'M' && datum.age > 15 && datum.age < 34,
style: {
fill: '#5D76A3'
}
}
]
}
]
},
{
title: [
{
text: '… two-thirds of whom are '
},
{
text: 'black',
fontWeight: 'bold'
},
{
text: '.'
}
],
nodes: [
{
query: datum => datum.intent === 'Homicide',
style: {
fill: '#C6CEDF'
},
children: [
{
query: datum => datum.sex === 'M' && datum.age > 15 && datum.age < 34,
style: {
fill: '#A6B3CC'
},
children: [
{
query: datum => datum.race === 'Black',
style: {
fill: '#5D76A3'
}
}
]
}
]
}
]
},
{
title: [
{
text: 'Women',
fontWeight: 'bold'
},
{
text: ' are far less likely to be gun homicide victims — about 1,700 of them are killed each year, many in '
},
{
text: 'domestic violence',
fontWeight: 'bold'
},
{
text: ' incidents.'
}
],
nodes: [
{
query: datum => datum.intent === 'Homicide',
style: {
fill: '#C6CEDF'
},
children: [
{
query: datum => datum.sex === 'F',
style: {
fill: '#5D76A3'
}
}
]
}
]
},
{
title: [
{
text: 'The remaining gun deawths are '
},
{
text: 'accidents',
fontWeight: 'bold'
},
{
text: 'or are classified as undetermined.',
fontWeight: 'bold'
}
],
nodes: [
{
style: {
fill: '#dedede'
}
},
{
query: datum => datum.intent === 'Accidental',
style: {
fill: '#D4BC45'
}
},
{
query: datum => datum.intent === 'Undetermined',
style: {
fill: '#999999'
}
}
]
},
{
title: [
{
text: 'The common element in all these deaths is a gun. But the causes are very different, and that means the solutions must be, too.'
}
],
nodes: [
{
query: datum => datum.intent === 'Suicide',
style: {
fill: '#e3662e'
}
},
{
query: datum => datum.intent === 'Homicide',
style: {
fill: '#5D76A3'
}
},
{
query: datum => datum.intent === 'Accidental',
style: {
fill: '#D4BC45'
}
},
{
query: datum => datum.intent === 'Undetermined',
style: {
fill: '#999999'
}
}
]
}
]
};
技术实现解析
技术实现部分只要是包含标题和单元组件的实现。
标题的实现非常简单,结合了目前 VStory 提供的Text和Rect组件还有动画的 API 就可以直接做出来。最主要的难点在于单元组件的实现。
单元组件实现
我在我的博客中曾写过单元组件的算法,在这边我重新用中文整理一下浓缩版本,详细可以在博客中查看。
问题定义
现在,让我们更正式地定义这个问题,我们用竖排列进行举例(实际上我们支持横排列,这只是横纵轴变化而已)
- 我们有一个固定大小的盒子,宽度为 ,高度为
- 我们想要在这个盒子里放入 个数据点(单位)。
- 我们希望单位之间有间隙。由于我们不知道单位的宽度和高度,所以我们将间隙定义为相对于盒子宽度和高度的比率 和 。因此, 且 ,其中 x 和 y 分别是单位的宽度和高度。
- 我们想要计算单位的宽度和高度,使得单位填满整个盒子,最外层的单位触及盒子的边缘,并且只允许最后一列的单位不完整。
例如,下图显示了当 ,,,,且 时的问题。算法应该计算单位的宽度和高度,以便我们能得到如下的可视化效果。
然而,你认为之前的问题总是有解吗?答案是否定的。因为如果我们把盒子的宽度稍微增大一点,但又不足以放入另一个单位,那么 就不会恰好等于
因此,我们需要引入一个新变量,称为 ,它表示两个单位之间的额外水平空间。这样我们就可以确保问题总是有解。
问题定义的最后一点变成: 我们要计算单位的宽度和高度,使得单位加上 填满整个盒子。最外层的单位应该触及盒子的边缘。并且我们只允许最后一列的单位不完整。
所以,我们的算法目标就变成了让 越小越好
求解方程
所以我们就变成了解下面这个方程,让越小越好,然后得到单元排布的行列数:
\begin{equation} n_{\text{col}} x + \left(n_{\text{col}} - 1\right) \cdot \left(x r_x + \text{offset}_x\right) = w \end{equation}
\begin{equation} n_{\text {row }} y+\left(n_{\text {row }}-1\right) \cdot y r_y=h \end{equation}
\begin{equation} n_{\text{col}} n_{\text{row}} \geq n \end{equation}
最后我们求解得到行数的限制条件为
\begin{equation} n_{\text{row}} \geq \left\lceil \frac{-b + \sqrt{b^2 - 4ac}}{2a} \right\rceil \end{equation}
其中 a,b,c 的定义为:
function getMinNRow(
box: [number, number],
aspectRatio: number,
gapRatio: [number, number],
n: number
): number {
const [w, h] = box;
const a = Math.pow(w * (1 + gap[1]), 2);
const b = gap[0] * aspectRatio * h - w * gap[1];
const c = -n * h * aspectRatio * (1 + gapRatio[0]) * (1 + gapRatio[1]);
const delta = Math.sqrt(b * b - 4 * a * c);
return Math.ceil((-b + delta) / (2 * a));
}
然后我们再从这个限制出发(这个限制已经很接近真正的行数),理论上平均 O(1) 的时间就可以找到真正的行数,来让 最小:
function getLayout(
minNRow: number,
count: number,
box: [number, number]
aspectRatio: number,
gapRatio: [number, number]
) {
const [w, h] = box;
let NRow = minNRow;
let NCol;
let x;
let y;
let totalWidth;
do {
y = h / (NRow * (1 + gapRatio[1]) - gapRatio[1]);
x = aspectRatio * y;
NCol = Math.ceil(count / NRow);
totalWidth = NCol * x + (NCol - 1) * gapRatio[0] * x;
} while (totalWidth > w && NRow++);
return { NRow, NCol, y, x };
}
得到行列数之后,我们就可以轻松画出所有单元点的排布,也就是这个单元组件。
完成的代码实现
这个是贡献的 PR:github.com/VisActor/VS…
其中主要包含两个核心:
联系我们
1)VisActor 微信订阅号留言(可以通过订阅号菜单加入微信群):
2)VisActor 官网:www.visactor.io/
3)VisActor 飞书群(推荐):
4)github:github.com/VisActor/VC…
5)Discord group:discord.gg/3wPyxVyH6m
- twitter:twitter.com/xuanhun1