ECharts绘图解决方案——流动关系图(桑基图)

应用场景

用流动关系图来映射品牌之间的有效换机数量,从而帮助运营对手机品牌的行情做分析和预测。

  • 图形说明
    • 一期:图形中间为分析主品牌;左侧为流入品牌,曲线粗细=换机数大小(流入量);右侧为流向品牌信息,曲线粗细=换机数大小(流出量);
    • 二期:为降低信息复杂度,中间品牌支持切换为单个品牌(观察品牌)。

最终实现效果如下图所示:

  • 一期
    展示品牌过多,线条过密,信息复杂度较高
  • 二期
    中间品牌支持切换为单个品牌

    增加对照品牌筛选

实现

  • 确定使用的基本图表类型及数据格式:使用桑基图,数据格式编码为节点列表和边列表。
  • 确定节点和边的样式、交互效果:不同品牌的节点和边需要明显的颜色区分;鼠标hover到边上时显示对应的品牌流向关系和有效换机数量。
  • 中间品牌支持切换为单个品牌(观察品牌)。
  • 支持观察品牌两边的颜色与两侧对照品牌颜色一致。
  • 支持鼠标hover到中间品牌时,单独查看其中一个对照品牌与观察品牌的流动关系,其他品牌图形信息置灰。

问题及解决方案

  • 后台返回的brand_flow_relation的数据格式如下图所示:
  • 为了更容易看清后续的处理逻辑,在这里先给出主要用到的变量类型定义:
    type brandType = {
      [index: string]: Array<number>
    }
    type brand_flow_relationType = {
      [index: string]: {
        brand_in: brandType,
        brand_out: brandType
      }
    }
    
    type nodesType = Array<{
      name: string,
      key?: string,
      depth?: number,
      itemStyle?: {[index: string]: string | number, opacity?: number},
      label?: {[index: string]: string | number},
      emphasis?: {[index: string]: string | number},
      tooltip?: {[index: string]: string | number},
    }> | Array<string>
    
    type linksType = Array<{
      source: string,
      target: string,
      value: Array<number> | number,
      valueShow?: number,
      lineStyle? : {[index: string]: string | number, opacity?: number},
      emphasis?: {[index: string]: string | number},
    }>
    

问题一:流入、中间、流出的品牌存在同名情况,而ECharts桑基图只支持有向无环图。

展示的品牌流动关系需要明确分为三列:流入品牌、中间品牌和流出品牌,而这三列存在名称重复的情况,即需要查看品牌A流入/流出品牌A自身(持机)的数量,然而根据series-sankey.links的配置规则,桑基图只支持有向无环图,“自身流向自身”显然是有环的,因此不支持直接输入所有流向关系。

  • 思路:在数据处理阶段,将流入、中间、流出的节点和表示边的相应两端节点名称均加以特定标记处理(如针对名称为A的节点,流入、中间、流出分别处理为:A(流入)、A、A(流出)),目的是让ECharts将这些节点都识别为唯一的;在实际的展示和交互上,再将对应标记去掉,还原实际名称。

  • 方案:这里可以简单的用“加空格”处理做区分,在还原时简单地“去掉空格”,比较省事。

  • 相关代码片段(节点、边初始化处理,重点看注释):

    // 节点初始化处理,从中间节点入手
    let nodes: nodesType = []
    if (brand_flow_relation && Object.keys(brand_flow_relation).length) {
      const centerNodes: Array<string> = Object.keys(brand_flow_relation)
      let brandInNodes: Array<string> = []
      let brandOutNodes: Array<string> = []
      centerNodes.forEach(cNode => {
        brandInNodes = [...brandInNodes, ...Object.keys(brand_flow_relation[cNode].brand_in)]
        brandOutNodes = [...brandOutNodes, ...Object.keys(brand_flow_relation[cNode].brand_out)]
      })
      nodes = [
        ...brandInNodes.map(name => `${name} `),  // 流入结点名称加1个空格
        ...centerNodes,                           // 中间结点不加空格
        ...brandOutNodes.map(name => `${name}  `) // 流出结点名称加2个空格
      ]
      // 节点去重
      nodes = [...new Set(nodes)]
      // 节点数据统一处理成ECharts规范格式
      nodes = nodes.map(_ => ({ name: _ }))
    }
    
    // 边初始化处理
    const links: linksType = Object.entries(brand_flow_relation).reduce((acc, [k, v]) => {
      const brandIns = Object.entries(v.brand_in).map(([key, val]) => {
        return {
          source: `${key} `, // 流入节点
          target: k, // 中间节点
          value: val
        }
      })
      const brandOuts = Object.entries(v.brand_out).map(([key, val]) => {
        return {
          source: k, // 中间节点
          target: `${key}  `, // 流出节点
          value: val
        }
      })
      return [...acc, ...brandIns, ...brandOuts]
    }, [])
    

    生成的配置项见# sankeyOptionExample1.js

  • 脑洞成果:

问题二:位于不同列的同品牌节点颜色需保持一致。

然而基于问题1的解决方案,对所有节点加入特定标记处理后,ECharts已将所有节点识别为互不关联的不同节点,因此均会会默认按不同节点来处理。

  • 思路:由于各品牌名称均为大类,即不存在名称重合度较高(如iPhone7、iPhone8)的情况,因此问题一的“加空格”处理方式仍然可行;否则需要结合实际情况做更复杂的处理。此时只需要将节点名称还原出原名称作为一个新字段作为“同名标识”,从调色盘颜色列表给不同的“同名标识”手动分配颜色。
  • 方案:“去掉空格”,取出节点的“同名标识”key,根据不同key给节点分配不同颜色。
  • 相关代码片段:
    const colors: Array<string> = [] // ['#3583FF', '#FB7962', '#A5D33E', ...]
    let nodeKeys: Array<string> = [] // 节点key(“同名标识”)列表
    nodes.forEach(elm => {
      const trimedElmName = elm.name.trim() // 取key
      nodeKeys.push(trimedElmName)
      if (elm.name.startsWith(trimedElmName)) { 
        elm.key = trimedElmName // 节点保存对应的key
      }
    })
    nodeKeys = [...new Set(nodeKeys)] // 去重
    const nodeKeyColorMap: {[index: string]: string} = {} // key-颜色映射表
    nodeKeys.forEach((key, index) => {
      nodeKeyColorMap[key] = colors[index % colors.length]
    })
    // 设置节点颜色
    nodes = nodes.map(node => ({
      ...node,
      itemStyle: {
        color: nodeKeyColorMap[node.key],
      }
    }))
    
    生成的配置项见# sankeyOptionExample2.js
  • 脑洞成果:

问题三:中间节点为单一节点时,边的颜色配置问题。

需要支持观察品牌两边的颜色与两侧对照品牌颜色一致。
而根据series-sankey.lineStyle的配置,lineStyle.color仅支持设置'source'或者'target'特殊值,或者一个单独的颜色值如'#314656',那么如果仅在外层lineStyle设置color: 'source'或'target',会导致有一侧的所有边颜色为单一颜色,如下图所示。

  • 思路:links是否支持对每个子项单独设置lineStyle?支持的值选项是否与外层一致?答案是肯定的。关键点在于流出边的source均为中间节点,此时只需处理这部分的边,将其lineStyle.color的值调换为'target'即可。
  • 方案:去掉外部统一的lineStyle配置项,只在links内部处理。
  • 相关代码片段:
    links = links.map(link => {
      const ret = {
        ...link,
        lineStyle: {
          color: 'source',
        }
      }
      if (link.source === centerNode) {
        ret.lineStyle.color = 'target'
      }
      return ret
    }
    
    生成的配置项见sankeyOptionExample3-2.js
  • 脑洞成果:

问题四:数据量级差异过大导致的交互体验问题。

在二期改造中,中间节点固定为一个时,放大了一个因为数据量级差异过大导致的交互体验问题:其中一边的线条过细且较密,交互区域过小且容易与其他边互相影响,如下图所示。


由于存在数据量级相差至少十倍的情况,导致部分线条过细

经对多组数据的观察,发现出现这种情况的原因是某些品牌的流入/流出其中一边的数据量级普遍是另一边的数十上百倍,边的粗细问题也因按比例渲染而受量级影响。

  • 思路:(1)首先想到的是在基于“单边数据较均匀(例如相差小于10倍),且其中一边的量级普遍与另一边相差一个相对定值”的前提下,可以考虑取两边各自的中位数,取其倍数,将量级小的一边乘以倍数处理;但与后台确认此前提不能确保成立,此方案废弃。
    (2)最终约定由后台做归一化处理,将原数据和归一化后的数据一起返回给前端。
  • 方案:取归一化的值用于图表渲染,增加一个字段保存实际值用于交互展示。
  • 相关代码片段:
    // 给links加入额外属性valueShow
    links = links.map(link => ({
      ...link,
      value: link.value[1], // 归一化后的值,用于渲染图表
      valueShow: link.value[0], // 真实值,用于交互展示
    }))
    // tooltip设置
    tooltip: {
      trigger: 'item',
      triggerOn: 'mousemove',
      formatter: (params) => {
        const { name, data } = params
        return data.name ? data.name.trim() :
          `${data.source.trim()} --> ${data.target.trim()} : ${data.valueShow}`
      }
    },
    
    生成的配置项见sankeyOptionExample4-2.js
  • 脑洞成果:

问题五:ECharts配置不支持鼠标hover到一个节点时,指定特定的相邻边高亮或置灰。

需要支持鼠标hover到中间品牌时,单独查看其中一个对照品牌与中间品牌的流动关系,其他品牌信息置灰。
而ECharts4.x开始支持的focusNodeAdjacency属性只支持批量设定相邻的一侧或所有边、节点的高亮,无法单独指定,如下图所示。

focusNodeAdjacency: "inEdges"("outEdges"效果类似)

focusNodeAdjacency: "allEdges"

  • 思路:利用元素透明度等配置项模拟置灰效果;指定对照节点名称流入、流出中间节点的值额外保存为中间节点的内容,用于鼠标hover展示。
  • 方案:实现过程中发现手动设定透明度定值无法做到与focusNodeAdjacency属性的置灰效果一致,从focusNodeAdjacency属性入手阅读ECharts源码发现,需要一条公式计算透明度。
  • 相关代码片段:
    /* 模拟置灰效果 */
    // centerNode: 中间节点,sideNode: 指定高亮节点
    nodes = nodes.map(node => {
      const ret = {
        ...node,
        itemStyle: {
          opacity: node.name === centerNode || (sideNode === '无' || node.key === sideNode) ? 1 : .1
        }
      }
      if (node.name === centerNode) {
        const flowInData = links.find(link => link.source.startsWith(sideNode) && link.target === centerNode)
        if (flowInData) {
          flowInData.valueShow = flowInData.value.length > 0 && flowInData.value[0]
          ret.flowInData = flowInData // 保存流入中间节点的关系
        }
        const flowOutData = links.find(link => link.target.startsWith(sideNode) && link.source === centerNode)
        if (flowOutData) {
          flowOutData.valueShow = flowOutData.value.length > 0 && flowOutData.value[0]
          ret.flowOutData = flowOutData // 保存从中间节点流出的关系
        }
      }
      return ret
    })
    
    links = links.map(link => {
      const ret = {
        ...link,
        value: link.value[1],
        valueShow: link.value[0],
        lineStyle: {
          color: 'source',
          curveness: 0.6,
          opacity: .2
        }
      }
      if (link.source === centerNode) {
        ret.lineStyle.color = 'target'
      }
      if (sideNode === '无') return ret
      const isSameNode = sideNode === centerNode
      const isDiffCompare = !isSameNode && !(link.source.startsWith(sideNode) || link.target.startsWith(sideNode))
      const isSameCompare = isSameNode && !(link.source.startsWith(sideNode) && link.target.startsWith(sideNode))
      if (isDiffCompare || isSameCompare) { // 不满足选中品牌条件关系时
        /*
         * 此计算规则参考了ECharts源码中的focusNodeAdjacency配置项,
         * 详见src/chart/sankey/SankeyView.js中的fadeOutItem函数
         */
        ret.lineStyle.opacity *= .1
      }
      return ret
    })
    
    /* 鼠标hover展示配置 */
    tooltip: {
      trigger: 'item',
      triggerOn: 'mousemove',
      formatter: (params) => {
        const { name, data } = params
        if (name === centerNode) {
          let ret = name.trim()
          const { flowInData, flowOutData } = data
          if (flowInData && Object.keys(flowInData).length) {
            ret += `<br />${flowInData.source.trim()} --> ${flowInData.target.trim()} : ${flowInData.valueShow}`
          }
          if (flowOutData && Object.keys(flowOutData).length) {
            ret += `<br />${flowOutData.source.trim()} --> ${flowOutData.target.trim()} : ${flowOutData.valueShow}`
          }
          return ret
        }
        return data.name ? data.name.trim() :
          `${data.source.trim()} --> ${data.target.trim()} : ${data.valueShow}`
      }
    },
    
    生成的配置项见sankeyOptionExample5.js
  • 脑洞成果:

效果样例

以上生成的配置项均可直接粘贴在https://echarts.apache.org/examples/zh/editor.html?c=line-simple查看效果。

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

推荐阅读更多精彩内容