树袋熊到最大值 – 案例研究

有一天,我在浏览 Reddit 时偶然发现了一个发布的奇怪链接:http://www.cesmes.fi/pallo.swf

这个游戏很有吸引力,我非常喜欢它,但我发现一些设计元素存在缺陷。为什么它一开始是四个圆圈而不是一个?为什么颜色分割如此刺眼?为什么是用 Flash 编写的?(这是 2010 年吗?)最重要的是,它错失了一个绝佳的机会,可以将圆圈分割成形成图像的点,而不是仅仅使用随机颜色。

创建项目

这似乎是一个有趣的项目,我使用 D3 使用 SVG 渲染重新实现了它(并进行了我的设计调整)。

主要思想是让点分割成图像的像素,每个较大的点都包含在其内部四个点的平均颜色(递归),并允许代码在任何基于 Web 的图像上工作。
代码在我的“项目”文件夹中放置了一段时间;情人节即将来临,我认为它可以作为一份可爱的礼物。我购买了域名,找到了一张可爱的图片,因此“koalastothemax.com (KttM)”诞生了。

实施

虽然 KttM 的用户界面部分自创建以来变化不大,但实现部分已多次重新访问,以合并错误修复、提高性能并为更广泛的设备提供支持。

下面提供了值得注意的摘录,完整的代码可以在 GitHub 上找到。

加载图像

如果图像托管在 koalastothemax.com(相同)域名上,则加载它就像调用 new Image() 一样简单。

var img = new Image();
img.onload = function() {
 // Awesome rendering code omitted
};
img.src = the_image_source;

KttM 的核心设计目标之一是让人们使用自己的图像作为显示的图像。因此,当图像位于任意域上时,需要对其进行特殊考虑。鉴于同源策略限制,需要有一个图像代理,可以从任意域中获取图像或将图像数据作为 JSONP 调用发送。

最初我使用了一个名为 $.getImageData 的库,但在 KttM 病毒式传播并将 $.getImageData App Engine 帐户推至极限后,我不得不切换到自托管解决方案。

提取像素数据

图像加载后,需要将其调整为圆圈最细层级的尺寸(128 x 128),并可以使用离屏 HTML5 canvas 元素提取其像素数据。

koala.loadImage = function(imageData) {
 // Create a canvas for image data resizing and extraction
 var canvas = document.createElement('canvas').getContext('2d');
 // Draw the image into the corner, resizing it to dim x dim
 canvas.drawImage(imageData, 0, 0, dim, dim);
 // Extract the pixel data from the same area of canvas
 // Note: This call will throw a security exception if imageData
 // was loaded from a different domain than the script.
 return canvas.getImageData(0, 0, dim, dim).data;
};

dim 是将出现在一侧的最小的圆圈数量。128 似乎产生了不错的效果,但实际上可以使用任何 2 的幂。最细层级上的每个圆圈对应于调整大小的图像的一个像素。

构建分割树

调整图像大小会返回渲染像素化最细层级所需的数据。每一层后续层级都是通过将四个相邻的点簇组合在一起并对其颜色取平均值形成的。整个结构存储为(四叉)树,以便当圆圈分割时,可以轻松访问其形成的点。在构建过程中,树的每一层后续层级都存储在一个高效的二维数组中。

// Got the data now build the tree
var finestLayer = array2d(dim, dim);
var size = minSize;

// Start off by populating the base (leaf) layer
var xi, yi, t = 0, color;
for (yi = 0; yi < dim; yi++) {
 for (xi = 0; xi < dim; xi++) {
   color = [colorData[t], colorData[t+1], colorData[t+2]];
   finestLayer(xi, yi, new Circle(vis, xi, yi, size, color));
   t += 4;
 }
}

首先遍历从图像中提取的颜色数据,并创建最细的圆圈。

// Build up successive nodes by grouping
var layer, prevLayer = finestLayer;
var c1, c2, c3, c4, currentLayer = 0;
while (size < maxSize) {
 dim /= 2;
 size = size * 2;
 layer = array2d(dim, dim);
 for (yi = 0; yi < dim; yi++) {
   for (xi = 0; xi < dim; xi++) {
     c1 = prevLayer(2 * xi    , 2 * yi    );
     c2 = prevLayer(2 * xi + 1, 2 * yi    );
     c3 = prevLayer(2 * xi    , 2 * yi + 1);
     c4 = prevLayer(2 * xi + 1, 2 * yi + 1);
     color = avgColor(c1.color, c2.color, c3.color, c4.color);
     c1.parent = c2.parent = c3.parent = c4.parent = layer(xi, yi,
       new Circle(vis, xi, yi, size, color, [c1, c2, c3, c4], currentLayer, onSplit)
     );
   }
 }
 splitableByLayer.push(dim * dim);
 splitableTotal += dim * dim;
 currentLayer++;
 prevLayer = layer;
}

创建最细的圆圈后,每个后续圆圈都是通过合并四个点并将结果点的半径加倍构建的。

渲染圆圈

构建分割树后,初始圆圈将添加到页面中。

// Create the initial circle
Circle.addToVis(vis, [layer(0, 0)], true);

这使用了 Circle.addToVis 函数,该函数在圆圈分割时使用。第二个参数是要添加到页面的圆圈数组。

Circle.addToVis = function(vis, circles, init) {
 var circle = vis.selectAll('.nope').data(circles)
   .enter().append('circle');

 if (init) {
   // Setup the initial state of the initial circle
   circle = circle
     .attr('cx',   function(d) { return d.x; })
     .attr('cy',   function(d) { return d.y; })
     .attr('r', 4)
     .attr('fill', '#ffffff')
       .transition()
       .duration(1000);
 } else {
   // Setup the initial state of the opened circles
   circle = circle
     .attr('cx',   function(d) { return d.parent.x; })
     .attr('cy',   function(d) { return d.parent.y; })
     .attr('r',    function(d) { return d.parent.size / 2; })
     .attr('fill', function(d) { return String(d.parent.rgb); })
     .attr('fill-opacity', 0.68)
       .transition()
       .duration(300);
 }

 // Transition the to the respective final state
 circle
   .attr('cx',   function(d) { return d.x; })
   .attr('cy',   function(d) { return d.y; })
   .attr('r',    function(d) { return d.size / 2; })
   .attr('fill', function(d) { return String(d.rgb); })
   .attr('fill-opacity', 1)
   .each('end',  function(d) { d.node = this; });
}

在这里,D3 的魔力发生了。circles 中的圆圈被添加到 (.append('circle')) SVG 容器中,并动画到其位置。初始圆圈会得到特殊处理,因为它从页面中心淡入,而其他圆圈则从其“父”圆圈的位置滑入。

以典型的 D3 风格,circle 最终成为所有已添加圆圈的选择。.attr 调用应用于选择中的所有元素。当传递一个函数时,它显示如何将分割树节点映射到 SVG 元素。

.attr('cx', function(d) { return d.parent.x; }) 将设置圆圈中心的 X 坐标为父元素的 X 位置。

属性设置为其初始状态,然后使用 .transition() 启动转换,然后属性设置为其最终状态;D3 会处理动画。

检测鼠标(和触摸)悬停

当用户将鼠标(或手指)悬停在圆圈上时,需要分割圆圈;为了高效地完成此操作,可以利用布局的规则结构。

所描述的算法大大优于本机的“onmouseover”事件处理程序。

// Handle mouse events
var prevMousePosition = null;
function onMouseMove() {
 var mousePosition = d3.mouse(vis.node());

 // Do nothing if the mouse point is not valid
 if (isNaN(mousePosition[0])) {
   prevMousePosition = null;
   return;
 }

 if (prevMousePosition) {
   findAndSplit(prevMousePosition, mousePosition);
 }
 prevMousePosition = mousePosition;
 d3.event.preventDefault();
}

// Initialize interaction
d3.select(document.body)
 .on('mousemove.koala', onMouseMove)

首先注册一个覆盖整个页面的 mousemove 事件处理程序。事件处理程序跟踪之前的鼠标位置,并调用 findAndSplit 函数,并将用户鼠标移动的线段传递给它。

function findAndSplit(startPoint, endPoint) {
 var breaks = breakInterval(startPoint, endPoint, 4);
 var circleToSplit = []

 for (var i = 0; i < breaks.length - 1; i++) {
   var sp = breaks[i],
       ep = breaks[i+1];

   var circle = splitableCircleAt(ep);
   if (circle && circle.isSplitable() && circle.checkIntersection(sp, ep)) {
     circle.split();
   }
 }
}

findAndSplit 函数将用户鼠标移动的可能较大的线段分割成一系列较小的线段(不超过 4px 长)。然后,它检查每个小线段是否存在潜在的圆圈交叉。

function splitableCircleAt(pos) {
 var xi = Math.floor(pos[0] / minSize),
     yi = Math.floor(pos[1] / minSize),
     circle = finestLayer(xi, yi);
 if (!circle) return null;
 while (circle && !circle.isSplitable()) circle = circle.parent;
 return circle || null;
}

splitableCircleAt 函数利用布局的规则结构来查找可能与以给定点结束的线段相交的圆圈。这是通过查找最近的细圆圈的叶节点并向上遍历分割树以找到其可见父节点来完成的。

最后,分割相交的圆圈 (circle.split())。

Circle.prototype.split = function() {
 if (!this.isSplitable()) return;
 d3.select(this.node).remove();
 delete this.node;
 Circle.addToVis(this.vis, this.children);
 this.onSplit(this);
}

病毒式传播

情人节后的某个时候,我与 Mike Bostock(D3 的创建者)会面,讨论了 D3 语法,并向他展示了 KttM,他认为值得发推文——毕竟,这是使用 D3 完成的无意义艺术可视化的早期示例。

Mike 拥有大量的 Twitter 粉丝,他的推文被 Google Chrome 开发团队的一些成员转发,开始获得一些关注度。

既然树袋熊已经“出笼”,我决定不妨将其发布到 Reddit 上。我将其发布到编程子版块,标题为“一个可爱的 D3/SVG 供电图像拼图。[无 IE]”,获得了 23 个赞,这让我很开心。当天晚些时候,它被重新发布到搞笑子版块,标题为“按所有点 :D”,并被投票到首页。

流量呈指数级增长。Reddit 是一个快速下降的峰值,但人们已经开始关注它并将其传播到 Facebook、StumbleUpon 和其他社交媒体平台。

来自这些来源的流量随着时间推移而逐渐减少,但每隔几个月,KttM 就会被重新发现,流量就会激增。

这种不规则的流量模式突出了编写可扩展代码的必要性。方便的是,KttM 在用户浏览器中完成了大部分工作;服务器只需要服务页面资产和每个页面加载时的一个(小)图像,从而允许 KttM 托管在廉价的共享主机服务上。

衡量参与度

KttM 变得流行之后,我开始研究人们如何实际与应用程序交互。他们是否意识到初始的单个圆圈可以分割?是否有人真正完成了整个图像?人们是否均匀地揭示圆圈?

最初,KttM 上唯一的跟踪是跟踪页面浏览量的普通 GA 代码。这很快变得乏善可陈。我决定在清除整个层级时以及分割一定百分比的圆圈时(以 5% 为增量)添加自定义事件跟踪。事件值设置为自页面加载以来的秒数。

如您所见,此类事件跟踪提供了见解和改进空间。当第一个圆圈分割时会触发 0% 清除事件,并且该事件触发的平均时间似乎为 308 秒(5 分钟),这听起来不合理。实际上,当有人打开 KttM 并将其保持打开状态数天,然后如果分割了一个圆圈,事件值将非常大,并且会扭曲平均值。我希望 GA 有一个直方图视图。

即使是基本的参与度跟踪也能深入了解人们在游戏中取得的进展。在升级鼠标悬停算法时,这些指标非常有用。在运行新算法几天后,我发现人们在放弃之前完成了更多拼图。

经验教训

在制作、维护和运行 KttM 的过程中,我了解了一些关于使用现代 Web 标准构建可在各种设备上运行的 Web 应用程序的经验教训。

一些本机浏览器实用程序为您提供了 90% 的所需功能,但要使您的应用程序完全按照您的意愿运行,您需要在 JavaScript 中重新实现它们。例如,SVG 鼠标悬停事件无法很好地处理大量圆圈,通过利用规则圆圈布局在 JavaScript 中实现它们效率更高。同样,本机 base64 函数 (atobbtoa) 并非普遍支持,也不适用于 Unicode。令人惊讶的是,支持现代 Internet Explorer(9 和 10)非常容易,对于较旧的 IE,Google Chrome Frame 提供了一个极好的后备方案。

尽管标准合规性有了巨大改进,但仍然有必要在各种浏览器和设备上测试代码,因为某些功能的实现方式仍然存在差异。例如,在运行在 Microsoft Surface 上的 IE10 中,需要添加 html {-ms-touch-action: none; } 以使 KttM 正确运行。

添加跟踪并花时间定义和收集关键参与度指标,使您能够以量化的方式评估部署到用户的更改的影响。拥有明确定义的指标使您能够运行受控测试,以了解如何简化您的应用程序。

最后,倾听用户的意见!他们会发现您错过的东西——即使他们自己也不知道。完成时出现的祝贺消息是在我收到用户投诉说不清楚何时完全揭示了图片后添加的。

所有项目都在不断发展,如果您倾听用户的意见并运行受控实验,那么您可以无限地改进。

关于 Vadim Ogievetsky

Vadim 是 Metamarkets 的开发人员,他使用 D3 在现代 Web 技术之上构建交互式数据驱动应用程序。在加入 Metamarkets 之前,Vadim 是斯坦福数据可视化小组的一员,他为 Protovis 和其他开源数据可视化项目做出了贡献。他现在专注于 DVL 的开源开发,这是一个基于 D3 构建的用于动态数据可视化的反应式数据流库。

更多 Vadim Ogievetsky 的文章…


14 条评论

  1. Coerv

    哇,它就像气泡膜和刮刮乐彩票的杂交。太棒了。:-)

    2013年1月10日 上午 03:40

    1. Robert Nyman

      哈哈!是的,我认为这是一个恰当的描述。:-)

      2013年1月10日 上午 03:43

  2. Will Mitchell

    非常好!我记得在 Reddit 上玩这个的时候很有趣。

    PS – 文章中的一些代码示例似乎对其 for 循环和 && 进行了 HTML 编码…

    2013年1月10日 下午 03:35

    1. Robert Nyman

      很高兴你喜欢它!
      感谢您提醒我格式问题,现在已修复。

      2013年1月11日 上午 02:34

  3. aL3xa

    可悲的是,在 SVG 渲染方面,Chrome 超过了 Firefox(即使是 Nightly)。在我的 Linux 系统上尤其明显。在 M$ Win 上,差异并不那么大,但在 Linux 上……糟糕!无论是 nouveau 还是专有驱动程序。=/

    2013年1月10日 下午 05:55

    1. Robert Nyman

      感谢您提供有关 Linux 性能的反馈。
      请随时 提交错误 并提供您掌握的信息,希望这可以帮助团队改进它。

      2013年1月11日 上午 02:35

  4. Adam

    不幸的是,完成后,用于粘贴自定义 URL 的文本框被确认信息隐藏了…

    2013年1月13日 下午 07:20

    1. Vadim Ogievetsky

      这必须是在移动浏览器上吧?似乎在小窗口中确实会被隐藏。这需要修复 :-)。感谢您的提示!

      2013年1月13日 下午 07:34

      1. Adam

        实际上不是,这是一台 1366 x 768 显示屏的笔记本电脑。图片勉强合适。在 Win 7 上尝试了 Firefox 和 Chrome。我认为它也与图片的垂直居中逻辑有关。如果将 css 规则更改为
        #cont { margin-top: -300px }

        2013年1月13日 下午 08:16

  5. Johan

    我认为在某人完成游戏后显示原始图像(没有所有难看的白线)会很好。

    2013年1月14日 上午 08:16

    1. Johan

      我的意思是说“点”而不是线。

      2013年1月14日 上午 08:17

  6. moshe

    不错!

    2013年1月14日 下午 04:35

  7. sanketh

    太棒了!!!!但我想知道如果我不了解所有这些代码,如何才能为我的图像制作它?

    2013年2月10日 上午 10:25

    1. Robert Nyman [编辑]

      嗯,您确实需要创建自己的图像并使用代码才能使用这样的功能。

      2013年2月12日 上午 03:26

本文的评论已关闭。