单元可视化:一点一滴,娓娓道来

410 阅读7分钟

本文来自 VisActor 开源贡献者:MengXi

github:github.com/mengxi-ream

设计说明

我们这次制作了一个单元可视化的模板,下面是一个简单的例子例子:

单元可视化中,每个单元表示一个数据点,它可以是一个人,一组人,或者一个物品等等。下面是一个非常著名的例子:

www.youtube.com/watch?v=DwK…

这个模板的应用范围很广,可以用于数据分析、科学研究、教育培训等多个领域。它为用户提供了一个强大而灵活的工具,帮助他们更好地理解和展示复杂的数量关系。

模板中“幕”的定义

就像电影中那样,一个单元可视化模板,可以包含多个幕,每一幕都支持标题和单元组件,来讲述一个事实或者故事。

模板中的组件

1. 标题

标题支持富文本的定义,可以定义标题的样式,颜色,大小等属性。并且标题的长宽都是可以自定义的。随着时间的变化,可以在不同幕出现不同的标题,和下面的单元组件进行组合叙事,并且支持动画效果。

2. 单元组件

单元组件是整个模板的核心,总的来说,它就是把一定数量的单元点,按照制定的规则,进行排列,并且可以自定义每个单元点的样式,颜色,大小等属性。并且支持动画效果。下面是我们要实现的细节:

  1. 单元点必须充满指定长方形区域,这个区域可以自定义 padding
  2. 支持单元点横向或者竖向排布
  3. 单元点可以自定义形状,可以是圆形,椭圆形,矩形,多边形等,但是形状的长宽比是固定的,否则无法通过数学快速计算排布
  4. 单元点可以自定义颜色,以及每个单元点之间横向和竖向的间距
  5. 不同幕之间的转化支持动画效果,包括错位时间消失(像上面例子中那样),或者淡入淡出等效果

模板输入

下面是关于模板输入的定义,对应上面提到的功能设计

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 提供的TextRect组件还有动画的 API 就可以直接做出来。最主要的难点在于单元组件的实现。

单元组件实现

我在我的博客中曾写过单元组件的算法,在这边我重新用中文整理一下浓缩版本,详细可以在博客中查看。

问题定义

现在,让我们更正式地定义这个问题,我们用竖排列进行举例(实际上我们支持横排列,这只是横纵轴变化而已)

  1. 我们有一个固定大小的盒子,宽度为 ww ,高度为 hh
  2. 我们想要在这个盒子里放入 nn 个数据点(单位)。
  3. 我们希望单位之间有间隙。由于我们不知道单位的宽度和高度,所以我们将间隙定义为相对于盒子宽度和高度的比率 rxr_xryr_y 。因此, gapx=rxx\text{gap}_x = r_x xgapy=ryy \text{gap}_y = r_y y,其中 x 和 y 分别是单位的宽度和高度。
  4. 我们想要计算单位的宽度和高度,使得单位填满整个盒子,最外层的单位触及盒子的边缘,并且只允许最后一列的单位不完整。

例如,下图显示了当 n=46n = 46w=盒子宽度w = \text{盒子宽度}h=盒子高度h = \text{盒子高度}rx=0.5r_x = 0.5,且 ry=1r_y = 1 时的问题。算法应该计算单位的宽度和高度,以便我们能得到如下的可视化效果。

然而,你认为之前的问题总是有解吗?答案是否定的。因为如果我们把盒子的宽度稍微增大一点,但又不足以放入另一个单位,那么 gapx\text{gap}_x 就不会恰好等于 rxxr_x x

因此,我们需要引入一个新变量,称为 offsetx\text{offset}_x ,它表示两个单位之间的额外水平空间。这样我们就可以确保问题总是有解。

问题定义的最后一点变成: 我们要计算单位的宽度和高度,使得单位加上 offsetx\text{offset}_x 填满整个盒子。最外层的单位应该触及盒子的边缘。并且我们只允许最后一列的单位不完整。

所以,我们的算法目标就变成了让 offsetx\text{offset}_x越小越好

求解方程

所以我们就变成了解下面这个方程,让offsetx\text{offset}_x越小越好,然后得到单元排布的行列数:

\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) 的时间就可以找到真正的行数,来让 offsetx\text{offset}_x 最小:

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. 单元可视化组件的实现
  2. 生成 VStory DSL 的实现

联系我们

1)VisActor 微信订阅号留言(可以通过订阅号菜单加入微信群):

2)VisActor 官网:www.visactor.io/

3)VisActor 飞书群(推荐):

4)github:github.com/VisActor/VC…

5)Discord group:discord.gg/3wPyxVyH6m

  1. twitter:twitter.com/xuanhun1