2018-01-05

Sequences sunburst

聊一聊Sunburst,光芒图。光芒图非常吸引我,以致于我找了D3.js实现的好几个光芒图的例子,最终选了Kerry Rodden实现的的光芒图来解析。

Kerry Rodden这幅光芒图交互性非常丰富,一眼就吸引了我。这张光芒图展示的是某个网站各个页面的访问量比例情况,将网站中的父页面与子页面的层级关系表现了出来,非常直观地看到各个层次页面的访问比例。另外该图也添加了鼠标悬停的交互功能,这一功能非常优雅,鼠标悬停时,不仅能展示出当前焦点上的页面的访问占比,而且能在图的侧面,显示出当前焦点上的页面对应的层级关系,非常友好的设计。

来看看一个个靓丽的瞬间:

鼠标悬停在home/product/search页面所对应的节点上,此时sunburst图的状态如下:

可以看到:

  1. 悬停的焦点页面路径上的页面所对应的节点均呈现高亮状态
  2. 圆周中心展现出当前焦点页面的访问比例
  3. 左上角,展示出页面的访问路径以及访问占比,作为sunburst图的辅助,非常棒

接下来,我们就来详细解析一下用D3.js是如何实现上面这幅魅力四射的图的。主要解析index.html和sequences.js文件,sequences.css和visit-sequences.csv文件不做详解

index.html——源码

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Sequences sunburst</title>
    <!-- 引入v4版本的d3.js库-->
    <script src="//d3js.org/d3.v4.min.js"></script>
    <!--引入google的一种字体样式 -->
    <link rel="stylesheet" type="text/css"
      href="https://fonts.googleapis.com/css?family=Open+Sans:400,600">
    <!--引进样式文件 -->
    <link rel="stylesheet" type="text/css" href="sequences.css"/>
  </head>
  <body>
    <div id="main">
      <!-- 定义左上角页面访问序列的容器 -->
      <div id="sequence"></div>
      <!-- 定义光芒图的容器 -->
      <div id="chart">
        <!-- 定义鼠标悬停时,解释说明文字所在的容器 ,默认隐藏-->
        <div id="explanation" style="visibility: hidden;">
          <span id="percentage"></span><br/>
          of visits begin with this sequence of pages
        </div>
      </div>
    </div>
    <!--定义图例所在容器 .以及切换是否显示的按钮-->
    <div id="sidebar">
      <input type="checkbox" id="togglelegend"> Legend<br/>
      <div id="legend" style="visibility: hidden;"></div>
    </div>
    <!--sequences.js文件中实现光芒图各种细节 -->
    <script type="text/javascript" src="sequences.js"></script>
    <script type="text/javascript">
      // Hack to make this example display correctly in an iframe on bl.ocks.org
      d3.select(self.frameElement).style("height", "700px");
  </script> 
  </body>
</html>

sequences.js——源码

// Dimensions of sunburst.
// 定义画布宽度
var width = 750;
// 定义画布高度
var height = 600;
// 定义放射状的圆周的半径
var radius = Math.min(width, height) / 2;

// Breadcrumb dimensions: width, height, spacing, width of tip/tail.
// 此处定义光芒图左上角的 辅助显示访问序列的元素的相关尺寸:宽、高、空隙、间隔等
var b = {
  w: 75, h: 30, s: 3, t: 10
};

// Mapping of step names to colors.
// 定义页面对应的颜色值
var colors = {
  "home": "#5687d1",
  "product": "#7b615c",
  "search": "#de783b",
  "account": "#6ab975",
  "other": "#a173d1",
  "end": "#bbbbbb"
};

// Total size of all segments; we set this later, after loading the data.
// 总的节点数目
var totalSize = 0; 
// 定义svg画布
var vis = d3.select("#chart").append("svg:svg")
     // 设置画布的宽度
    .attr("width", width)
     // 设置画布的高度
    .attr("height", height)
     // 添加g元素
    .append("svg:g")
     // 设置g元素的id
    .attr("id", "container")
     // 定位g元素到画布中心
    .attr("transform", "translate(" + width / 2 + "," + height / 2 + ")");

// 此处用d3.partition()来定义分区布局函数partition()
var partition = d3.partition()
     //设置布局的尺寸,由于是圆形的布局方式,因此尺寸大小通过[2 * Math.PI, radius * radius]确定
    .size([2 * Math.PI, radius * radius]);

// 定义一个圆弧生成器
var arc = d3.arc()
    // 设置圆弧的起始角度的获取方式
    .startAngle(function(d) { return d.x0; })
    // 设置圆弧的结束角度的获取方式
    .endAngle(function(d) { return d.x1; })
    // 设置圆弧的内半径的获取方式
    .innerRadius(function(d) { return Math.sqrt(d.y0); })
    // 设置圆弧的外半径的获取方式
    .outerRadius(function(d) { return Math.sqrt(d.y1); });

// Use d3.text and d3.csvParseRows so that we do not need to have a header
// row, and can receive the csv as an array of arrays.
// d3.text(url,[callback])用来读取url指定的文本文件,并对其执行callback函数
d3.text("visit-sequences.csv", function(text) {
  // 将文本文件按行转换为数组
  var csv = d3.csvParseRows(text);
  
  // 将数组文件转换为以root为根节点的树形层次结构
  var json = buildHierarchy(csv);
  // 将树形结构的数据构造成可以用于可视化的形式
  createVisualization(json);
});

// Main function to draw and set up the visualization, once we have the data.
// 该方法用来将数据构造成可用于可视化的形式
function createVisualization(json) {

  // Basic setup of page elements.
  // 初始化光芒图左上角的表示页面访问序列的元素
  initializeBreadcrumbTrail();
  // 绘制图例legend
  drawLegend();
  
  // 切换是否显示图例
  d3.select("#togglelegend").on("click", toggleLegend);

  // Bounding circle underneath the sunburst, to make it easier to detect
  // when the mouse leaves the parent g.
  // 添加一个透明度为0的圆,来辅助监测鼠标动作的离开
  vis.append("svg:circle")
      .attr("r", radius)
      .style("opacity", 0);

  // Turn the data into a d3 hierarchy and calculate the sums.
  // 对数据进行层级布局
  var root = d3.hierarchy(json)
      .sum(function(d) { return d.size; })
      .sort(function(a, b) { return b.value - a.value; });
  
  // For efficiency, filter nodes to keep only those large enough to see.
  // 为了提高效率,将值过于小的节点过滤掉,只留较大节点进行显示
  // partition()将root数据进行分区布局,类似树型结构,然后通过descendants将布局后的
  // 数据结构按照从根节点开始,以拓扑顺序跟随子节点进行排序,最后返回拓扑排序的节点数组
  var nodes = partition(root).descendants()
      .filter(function(d) {
          // 弧度大于0.005的节点保留
          return (d.x1 - d.x0 > 0.005); // 0.005 radians = 0.29 degrees
      });
  
  // 绘制圆弧
  var path = vis.data([json]).selectAll("path")
      .data(nodes)
      .enter().append("svg:path")
      .attr("display", function(d) { return d.depth ? null : "none"; })
      .attr("d", arc)
      .attr("fill-rule", "evenodd")
      .style("fill", function(d) { return colors[d.data.name]; })
      .style("opacity", 1)
      .on("mouseover", mouseover);

  // Add the mouseleave handler to the bounding circle.
  // 设置鼠标离开的事件监听
  d3.select("#container").on("mouseleave", mouseleave);

  // Get total size of the tree = value of root node from partition.
  totalSize = path.datum().value;
 };

// Fade all but the current sequence, and show it in the breadcrumb trail.
// 鼠标移动在当前节点上时,显示当前节点的路径,并且将该路径显示在左上角的序列中
function mouseover(d) {
  // 计算当前节点占比
  var percentage = (100 * d.value / totalSize).toPrecision(3);
  var percentageString = percentage + "%";
  if (percentage < 0.1) {
    percentageString = "< 0.1%";
  }
  // 左上角的序列中显示当前节点的百分比文字
  d3.select("#percentage")
      .text(percentageString);

  // 填充圆弧中心的解释性文字
  d3.select("#explanation")
      .style("visibility", "");

  // ancestors()从当前节点开始,返回祖先节点的数组,一直到根节点结束
  // reverse()将该数组反转
  var sequenceArray = d.ancestors().reverse();
  // 反转后根节点位于第一个位置,将其移除
  sequenceArray.shift(); // remove root node from the array
  // 绘制坐上角的序列图形
  updateBreadcrumbs(sequenceArray, percentageString);

  // Fade all the segments.
  // 先将所有的path元素的透明度都设置为0.3,以便后面高亮显示当前路径上的节点元素
  d3.selectAll("path")
      .style("opacity", 0.3);

  // Then highlight only those that are an ancestor of the current segment.
  // 将当前节点的所有父节点高亮显示,将其透明度设置为1
  vis.selectAll("path")
      .filter(function(node) {
                return (sequenceArray.indexOf(node) >= 0);
              })
      .style("opacity", 1);
}

// Restore everything to full opacity when moving off the visualization.
// 当鼠标离开时,将所有的元素恢复为透明度为1的状态
function mouseleave(d) {

  // Hide the breadcrumb trail
  // 将左上角的序列隐藏
  d3.select("#trail")
      .style("visibility", "hidden");

  // Deactivate all segments during transition.
  // 先停止在鼠标移动经过节点时的动作监听
  d3.selectAll("path").on("mouseover", null);

  // Transition each segment to full opacity and then reactivate it.
  // 将所有元算设置为透明度1,并且启动mouseover监听
  d3.selectAll("path")
      .transition()
      .duration(1000)
      .style("opacity", 1)
      // 重新启用mouseover监听
      .on("end", function() {
              d3.select(this).on("mouseover", mouseover);
            });
  // 隐藏圆弧中间的百分比信息
  d3.select("#explanation")
      .style("visibility", "hidden");
}

// 该方法用来初始化光芒图的左上角的用于辅助显示访问序列的元素
function initializeBreadcrumbTrail() {
  // Add the svg area.
  // 创建一个svg画布
  var trail = d3.select("#sequence").append("svg:svg")
       // 设置画布的宽度
      .attr("width", width)
       // 设置画布的高度
      .attr("height", 50)
       // 设置画布的id
      .attr("id", "trail");
  // Add the label at the end, for the percentage.
  // 在页面序列元素后,添加显示序列访问频率的百分比的文本显示元素
  trail.append("svg:text")
    .attr("id", "endlabel")
    .style("fill", "#000");
}

// Generate a string that describes the points of a breadcrumb polygon.
// 生成绘制左上角序列多边形图形的路径数据
function breadcrumbPoints(d, i) {
  var points = [];
  points.push("0,0");
  // 变量b是在前面定义的关于 序列多边形的相关尺寸数据
  points.push(b.w + ",0");
  points.push(b.w + b.t + "," + (b.h / 2));
  points.push(b.w + "," + b.h);
  points.push("0," + b.h);
  if (i > 0) { // Leftmost breadcrumb; don't include 6th vertex.
    points.push(b.t + "," + (b.h / 2));
  }
  return points.join(" ");
}

// Update the breadcrumb trail to show the current sequence and percentage.
// 根据当前鼠标悬浮的节点的路径,更新左上角的序列
function updateBreadcrumbs(nodeArray, percentageString) {

  // Data join; key function combines name and depth (= position in sequence).
  // 根据name 和depth来计算节点在序列中的位置
  // 为序列绑定数据nodeArray
  var trail = d3.select("#trail")
      .selectAll("g")
      .data(nodeArray, function(d) { return d.data.name + d.depth; });

  // Remove exiting nodes.
  // 删除多于的元素
  trail.exit().remove();

  // Add breadcrumb and label for entering nodes.
  // 根据节点的个数在生成对应的显示node的元素节点数目
  var entering = trail.enter().append("svg:g");
  
  // 绘制左上角的序列的图形,以多边形polygon元素来绘制
  entering.append("svg:polygon")
      .attr("points", breadcrumbPoints)
      .style("fill", function(d) { return colors[d.data.name]; });

  entering.append("svg:text")
      .attr("x", (b.w + b.t) / 2)
      .attr("y", b.h / 2)
      .attr("dy", "0.35em")
      .attr("text-anchor", "middle")
      .text(function(d) { return d.data.name; });

  // Merge enter and update selections; set position for all nodes.
  entering.merge(trail).attr("transform", function(d, i) {
    return "translate(" + i * (b.w + b.s) + ", 0)";
  });

  // Now move and update the percentage at the end.
  d3.select("#trail").select("#endlabel")
      .attr("x", (nodeArray.length + 0.5) * (b.w + b.s))
      .attr("y", b.h / 2)
      .attr("dy", "0.35em")
      .attr("text-anchor", "middle")
      .text(percentageString);

  // Make the breadcrumb trail visible, if it's hidden.
  d3.select("#trail")
      .style("visibility", "");

}

// 绘制图列
function drawLegend() {

  // Dimensions of legend item: width, height, spacing, radius of rounded rect.
  // 设置图列的一些尺寸信息:宽、高、间隔、矩形圆角半径
  var li = {
    w: 75, h: 30, s: 3, r: 3
  };

  // 定义图列的画布
  var legend = d3.select("#legend").append("svg:svg")
      // 设置图例宽度
      .attr("width", li.w)
      // 设置图列高度
      .attr("height", d3.keys(colors).length * (li.h + li.s));

  // 定义g元素,用来绘制图例
  var g = legend.selectAll("g")
       // d3.entries(colors)将colors数组转换成对象数组,每个对象由key,value字段组成
       // 将转换后的数据绑定到g元素上,转换后的数据,key为页面名称,value为页面颜色
      .data(d3.entries(colors))
      .enter().append("svg:g")
      // 定位每个g元素,所有图列元素排成一列
      .attr("transform", function(d, i) {
              return "translate(0," + i * (li.h + li.s) + ")";
           });

  // 以矩形来表示每个图例
  g.append("svg:rect")
      .attr("rx", li.r) // 设置矩形的圆角半径
      .attr("ry", li.r) // 设置矩形的圆角半径
      .attr("width", li.w)  // 设置矩形的宽度
      .attr("height", li.h) // 设置矩形的高度
      // 用网页对应的颜色来填充对应的图例
      .style("fill", function(d) { return d.value; });

  // 为每个图例矩形添加文本描述
  g.append("svg:text")
      .attr("x", li.w / 2)
      .attr("y", li.h / 2)
      .attr("dy", "0.35em")
      .attr("text-anchor", "middle")
      .text(function(d) { return d.key; });
}

// 控制图列的显示与隐藏
function toggleLegend() {
  var legend = d3.select("#legend");
  if (legend.style("visibility") == "hidden") {
    legend.style("visibility", "");
  } else {
    legend.style("visibility", "hidden");
  }
}

// Take a 2-column CSV and transform it into a hierarchical structure suitable
// for a partition layout. The first column is a sequence of step names, from
// root to leaf, separated by hyphens. The second column is a count of how 
// often that sequence occurred.
// 采取两列csv文件的方式,将csv数据转换成分区布局所需要的结构格式
// 第一列表示访问序列的名字,name字段,访问序列从父页面到叶子页面;
// 第二列表示name字段对应的序列的访问频率
// 两列之间用逗号隔开
function buildHierarchy(csv) {
  var root = {"name": "root", "children": []};
  for (var i = 0; i < csv.length; i++) {
    // csv[i][0]中存储的是将要放进name字段的值
    var sequence = csv[i][0];
    // csv[i][1]中存储的是sequence序列的访问频率,是数字类型
    var size = +csv[i][1];
    if (isNaN(size)) { // e.g. if this is a header row
      continue;
    }
    // 由于sequence都是以中杠将访问序列中的页面名称连接的,此处以中杠作为分隔符将其转换为数组
    var parts = sequence.split("-");
    // 先初始化当前节点
    var currentNode = root;
    for (var j = 0; j < parts.length; j++) {
      // 初始化当前节点的children字段
      var children = currentNode["children"];
      // 获取当前节点的名称
      var nodeName = parts[j];
      var childNode;
      if (j + 1 < parts.length) {
   // Not yet at the end of the sequence; move down the tree.
    // 若未到序列的最后,则继续进行
    var foundChild = false;
    for (var k = 0; k < children.length; k++) {
      if (children[k]["name"] == nodeName) {
        childNode = children[k];
        foundChild = true;
        break;
      }
    }
  // If we don't already have a child node for this branch, create it.
  // 若此节点还没有创建子节点,那么为其创建
    if (!foundChild) {
      childNode = {"name": nodeName, "children": []};
      children.push(childNode);
    }
    currentNode = childNode;
      } else {
    // Reached the end of the sequence; create a leaf node.
    // 若已经到序列的结尾,则创建叶子节点,叶子节点和中间节点不同,叶子节点由name和size字段组成
    childNode = {"name": nodeName, "size": size};
    children.push(childNode);
      }
    }
  }
  return root;
};

至此,sunburst图的实现源码解释完毕。这篇写的很慢,因为中途有一周在出差,耽误了。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容

  • 总结包括:1 了解前端打包方案的黑暗历史2 模块化发展3 Webpack3 打包流程 在说webpack之前, 我...
    NOTEBOOK2阅读 219评论 0 1
  • SDWebImage 加载显示 GIF 与性能问题 SDWebImage 4.0 之前,可以用 UIImageVi...
    H_子非魚阅读 142评论 0 2
  • 2018的元月一日,做晚饭时,在削马蹄皮时把左手中指割了,瞬间鲜血从手指头涌出来,很多血,我马上用水清洗了一下,却...
    洁芸阅读 698评论 0 0
  • 2017年7月25日 连续十几天40℃高温,喝茶降暑是不错的选择,让我们一起来品茶聊天可好。 各种花茶有你喜欢的一...
    思念的诗阅读 172评论 0 0