有一天,我在浏览 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 函数 (atob
、btoa
) 并非普遍支持,也不适用于 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 构建的用于动态数据可视化的反应式数据流库。
14 条评论