Zonelyn · 2020年01月13日

纵览全局的博客文章组织模式-想法和基于Hexo的实现

欢迎访问个人博客:TZLoop's Blog (zonelyn.com)

转载本文请注明原始出处。

有人说蚂蚁的世界是二维的(非常不准确),那是因为它们永远不知道何为高矮深浅。
因为它们感官的“无能”,致使它们丧失感受世间万物的机会。

对知识系统(eg.博客)而言,良好的组织结构是极为重要的,尤其是当内容增多,关联复杂后显得尤为重要。传统的“分类(Categories)+标签(Tags)”的二级模式虽足以应付大部分用户的需求,但本质上其还是需要用户对已有分类和标签有良好的组织,这对很多用户来说是根本做不到,因为我们往往缺的就是这种“纵览全局”的能力。

分类往往越分越多,标签也是随意放置,久而久之,不仅已有的分类和标签杂乱无章,更为甚者是新增内容时根本不知从何下手,往往需要遍历过往的标签和分类,才能做出最终定夺。现在,通过图布局的方式,可以以一种近乎完美的方式对复杂的内容进行组织,详细效果请查看 该页面

纵览全局

对于知识系统(之后均以博客代指)而言,传统的模式只是简单的分支,或者称其为树形结构,在探索过程中,用户就如同“蚂蚁”一样,只得选择先从哪进入,然后再进入到哪里。对于单篇内容而言并无影响,但当需要感知全局时,往往这种模式就会出现问题。

分级/树形组织方式的不足

  • 用户开始便直接希望查阅某些内容,且不确定分类时,无法定位(局部要求)

可以通过搜索功能完成该需求。

  • 新增分类和标签时,缺少对已有项的感知能力(全局要求)

尤其对于标签,会更加的随意和杂乱,会出现重复、同义等等问题,在每次打标签时都要头疼一番。

  • 对于所打的标记,没有评价方法,永远不知道分类和标签是否匹配(全局要求)

对于已存在的标签或分类,这样打标签是否合理,由于标签的“松散”特性,不同分类中可以出现同一标签,这样在传统分级模式下,分类和标签的契合程度如何,系统的维护者无从知晓。

天然的解决方案:图布局

分级/树形标记模式本身就是一个分类过程,自己的知识内容(博客文章)是对象,维护者将其放置在不同的类别下。标签(Tags)则更像是分类过程中的副产物,更贴近文章内容,但又言简意赅,通过分级的思考方式,分类和标签和文章的关系是:

分类-标签-文章(1:M:N)

对于上述关系,分别用A、B、C表示的话,则整个系统其实就是一个“Ai-Bi-Ci”的三元组集合。该集合的好坏(即质量)就是其在语义上的契合程度,例如:

分类:军事 -> 标签:爆炸 -> 文章:伊拉克遭遇恐怖袭击
分类:娱乐 -> 标签:爆炸 -> 文章:阿富汗遭遇恐怖袭击

当抽象为网络/图之后,军事类别和娱乐类别会通过“爆炸”这一标签相连,如是,明显的会发现“爆炸”位置不对。(虽然例子很蠢,但当语义区分模糊、标签数量繁多时,极易出现该情况)。下面直接拿已完成的布局来解释:

粉红色为分类、蓝色为标签、节点半径为被使用的次数

  • 语义不符的连接点(异常的跨类标签),如果连接点对某一方语义不匹配,那么很可能该文章是特殊的,或者该标签不应该出现在该文章。(下图里可视化的文章在这儿,属于特殊文章,正常“生活分类”和“可视化”的语义并不匹配)

[图片上传失败...(image-3cbab4-1578849188423)]

  • 合格的连接点(跨分类的标签):虽然标签出现在不同分类中是非常正常的,例如“总结”,可以出现在任何分类中。但类似“总结”这类标签往往数量很多,即多次的出现在不同的类别中,那我们就说这是一个合格的跨分类标签

image.png

  • 对于分类点,以本博客为例,由于是对已存在数据进行分析,所以如果某分类下属节点不足,那么高度怀疑该分类不合理,除非是需要日后扩充的分类。这一需求在图布局的视图下非常容易分辨出来,合格的类别应该有众多叶节点,当叶节点不足,则应考虑将其降级至标签。(例如下图中的“朴素贝叶斯”,可将其降级为标签,并归类到“研究方向”中)

image.png

值得注意的一点是: 这里使用的图布局使用力导向(Force-directed)布局算法,相关则相近,无关则疏远,又完美的给布局结果以语义上的解释,即:

  • 当两个类别及其叶子节点距离很远时,其两者基本无关
  • 当两个类簇距离很近时,其高度相关

image.png

反推设计

上节中的分析看似很有道理,布局结果的使用也非常方便,那么如何从无到有将其构建出来?主要有以下几个方面:

  • 天然的三元组集合:文章的特性(篇幅长)决定了其不能参与整个构建和评价过程,那么剩下的二元组是天然的“关系数据”,对于关系数据的可视化,图布局算法/模式首当其冲。
  • 分析需要呈现的维度:对于任意节点(布局时类别和标签并无分别)来说,主要有以下维度信息:

    1. 自己(如果是类别)包含哪些标签;
    2. 自己是什么类型的节点(类别?标签?);
    3. 自己被使用了几次;
  • 对应的可视化要素:
    a. 图中节点的邻节点(点、线)
    b. 类别为粉色标签为蓝色(颜色)
    c. 次数与节点的半径成比例(圆面积)
  • 还可以附着信息(扩展维度)的要素:

    1. 节点的形状(三角形、圆、方)
    2. 连线的颜色(红、蓝)
    3. 连线的线型(虚线、实线)

上述过程中,确定“图布局”模式是基础,剩下的无非是将信息绑定到可视化元素上,例如,已实现的布局将“类别/标签”用颜色区分,其实用形状等其他可视化元素区分也完全可以。

垂直打击

到此为止,只是上层结构,类似数据库存储,搞了半天只是在搞索引,并没有触碰到数据,所以目前为止该网络并没有直通最底层(文章内容)的能力,这个问题恰好被Hexo的文件结构所解决,Hexo给每个标签和每个分类都渲染了单独的页面,关联的文章被放置在页面中,在此,直接通过节点的文本信息构造访问地址,将其绑定到文本上,即可点击后跳转到相关页面,虽然不是直接跳转文章,但也可以说具备相当的垂直打击能力了。

进阶版本:变的更强

简单粗暴的加入之前三元组被抛弃掉的文章信息,但由于加入后过于散乱,所以有必要将文章信息固定,以便于视觉呈现。如下图(d3.js实现的、用于可视化编程概念的可视化模型):

image.png

上图就是简单的带固定节点的力导向布局,但其实现代码比较复杂,目前处在构造数据阶段。一般的可视化模型套用的步骤:

阅读原站代码 -> 从原站抽离可视化部分 -> 搞清调用数据的方法及格式 -> 构造同样的数据 -> 独立运行 -> 放回自己的站点内

问题迎刃而解

到此,对于分级/树形分类的三点不足,可以发现很轻松就可以解决。既有全局视角,又可以同时具备直达的能力,对于组织内容数量较高(超过50)的站点非常适合该模式的导航、或辅助探索。

image.png

下文开始,详细记录了如何在Hexo博客中实现用图组织内容的方法,但是,请注意:以下内容并非操作教程,仅表明相信思路以供参考,或许您可以实现出更好的版本,但仅依照下文内容并不保证一定能重现,一些尝试和debug的细节过于繁琐并未列出,如有疑问欢迎留言。

代码实现

hexo.extend.helper.register

文档说明,借助该函数,可以在Hexo渲染生成页面文件之前,完成用户的自定义JavaScript代码。

其实,在Hexo的框架内,ejs(或其他类型的)模板中的代码就是渲染生成html的代码,在这些页面中,借助Hexo内建的对象,比如.post对象和.achieves对象,可以访问到其中保存的全部文章信息及关联信息。例如:

let posts = hexo.locals.get('posts');
let Xtags = posts.data[x].tags
let tagsY = Xtags.data[y].name

上述内容,可以最终得到第X篇文章(POST)中的第Y个标签的文本。类似的方法同样可以得到某篇文章的Categories的信息。这就是构造可视化数据的基本方法。(在渲染前构造、借助.post对象)
关于位置,在ejs模板中放置构造代码当然可以,但是不优雅,Hexo中建议的插入方式是:

  1. 在专门放置自定义JavaScript处理逻辑的文件中(plugin.js)放入代码,并使用内建函数。
  2. 在ejs(或其他)模板的相关位置,使用<%%>方式调用上述内建函数
  3. 使用console.log在渲染html时(hexo generate时的黑框)输出至Console里,拿到输出数据,放入到可视化的页面中即可。
  4. 或者一气呵成,直接将可视化的代码写入ejs模板中,即第一次渲染结束时产生的html就已经完成可视化页面的生成。

由于处在尝试阶段,所以这里使用步骤3 的方法,这样各模块相对独立,对主题源代码入侵小。

可视化页面

这里采用的是 D3.js 进行的可视化呈现,基本上是复用的 d3 的官方模板,但将文本信息一并和节点进行可视化展示。这段代码首先需要被抽取出来,这对于 d3 来说非常简单,只需注意引入的JavaScript库以及使用的json文本数据。


<svg width="1000" height="1000"></svg> //d3绘制的内容全部放置在该画布上
<script src="https://d3js.org/d3.v4.min.js"></script> 
<script>

  var sss = 'JSON字符串'; //这就是整个代码所可视化的数据

  var abc = parseInt($(".card").css("width").replace("px",""));
  if(abc>1080) abc=1050;
  else if(abc>1040) abc=1020;
  else abc=abc-40;
  $("svg").css("width",abc);
  $("svg").css("height",abc); //此部分将画布大小跟随文章页宽度变化

  var svg = d3.select("svg"),
    width = abc,
    height = abc;

  var color = d3.scaleOrdinal(d3.schemeCategory20);

  var simulation = d3.forceSimulation()
    .force("link", d3.forceLink().id(function(d) { return d.id; }))
      .force("charge", d3.forceManyBody().strength(-180).distanceMin(10).distanceMax(300).theta(1))
      .force("center", d3.forceCenter(width / 2 - 40, height / 2 - 30));

  var graph = JSON.parse(sss);

  var link = svg.append("g")
      .attr("class", "links")
    .selectAll("line")
    .data(graph.links)
    .enter().append("line")
      .attr("stroke-width", function(d) { return Math.sqrt(d.value); });

  var node = svg.append("g")
      .attr("class", "nodes")
    .selectAll("g")
    .data(graph.nodes)
    .enter().append("g")
    
  var circles = node.append("circle")
      .attr("r", function(d) { 
            if(d.group>=100) return d.group/100*(10.00/48.00)+1; //取整
            else return d.group+1;
        })
      .attr("fill",  function(d) { 
            if(d.group>=100) return "#ff4081";
            else return "#3f51b5";
        })
      .call(d3.drag()
          .on("start", dragstarted)
          .on("drag", dragged)
          .on("end", dragended));

  var lables = node.append("text")
      .html(function(d) {
        if(d.group>=100) {
            var p = d.group/100*(10.00/48.00)+10;
            return "<a style='font-size:"+p+"px;font-weight:600;color:red' href='/categories/"+d.id.replace("_","-")+"'>"+d.id+"</a>";
        }else{
            var q = d.group+10;
            return "<a style='font-size:"+q+"px;' href='/tags/"+d.id+"'>"+d.id+"</a>";
            }
      })
      .attr('x', function(d) { 
            if(d.group>=100) return d.group/100*(10.00/48.00)+5; //取整
            else return d.group+3;
        })
      .attr('y',function(d) { 
            if(d.group>=100) return d.group/100*(3.00/48.00)+5; //取整
            else return 5;
        });

  node.append("title")
      .text(function(d) { return d.id; });

  simulation
      .nodes(graph.nodes)
      .on("tick", ticked);

  simulation.force("link")
      .links(graph.links);

  function ticked() {
    link
        .attr("x1", function(d) { return d.source.x; })
        .attr("y1", function(d) { return d.source.y; })
        .attr("x2", function(d) { return d.target.x; })
        .attr("y2", function(d) { return d.target.y; });

    node
        .attr("transform", function(d) {
          return "translate(" + d.x + "," + d.y + ")";
        })
  }

function dragstarted(d) {
  if (!d3.event.active) simulation.alphaTarget(0.3).restart();
  d.fx = d.x;
  d.fy = d.y;
}
function dragged(d) {
  d.fx = d3.event.x;
  d.fy = d3.event.y;
}
function dragended(d) {
  if (!d3.event.active) simulation.alphaTarget(0);
  d.fx = null;
  d.fy = null;
}
</script>

构造数据格式

需要匹配示例的输入格式,这样才能最大化的复用代码。上述内容的官方示例中使用的格式是:

{
  "nodes": [
    {"id": "Myriel", "group": 1},
    ... ...
    {"id": "Mme.Hucheloup", "group": 8}
  ],
  "links": [
    {"source": "Napoleon", "target": "Myriel", "value": 1},
    ... ...
    {"source": "Mme.Hucheloup", "target": "Enjolras", "value": 1}
  ]
}

即,需要在可视化页面被渲染出来之前就得到上述格式的数据,这便是要借助Hexo的辅助函数来完成,将构造数据的代码封装成一个函数,然后在适当的ejs模板中调用一下,即可在 hexo generate 之后,从Console中拿到构造好的数据。

在此,构造规则是:类别永远单向的指向标签,类别不互连,标签不互连,同时,还需要计算的是类别和标签出现的次数

hexo.extend.helper.register('getPostData', () => {

    var posts = hexo.locals.get('posts');
    var tagsMap = new Map(); //counter

    // 利用posts对象获取类名和标签名
    for(var i = 0; i< posts.length; i++){
        var nameCS;
        posts.data[i].categories.forEach(function(k, v) {
            nameCS = k.name;
            return;
        })
        for(var j = 0; j< posts.data[i].tags.length; j++){
            var pname = posts.data[i].tags.data[j].name;
            var pval = tagsMap.get(pname);
            if(pval != null){  
                // 将类名和标签名压制在一起
                tagsMap.set(nameCS+">"+pname, parseInt(tagsMap.get(pname))+1);
            }else{
                // 
                tagsMap.set(nameCS+">"+pname, 1);  
            }
        }
    }
    //由此开始,构造符合特定格式的JSON字符串  
    let obj= [];
    let setss =  new Map();
    for (let[k,v] of tagsMap) {
        var st = k.split(">");
        var str = {};
        str.source = st[0];
        str.target = st[1];
        str.value  = v;
        obj.push(str);
        if(setss.get(st[0]) != null){  
            // 类节点 每次加100
            setss.set(st[0], parseInt(setss.get(st[0]))+100);
        }else{
            //
            setss.set(st[0], 100);
        }
        if(setss.get(st[1].trim()) != null){  
            // 标签节点 每次加1
            setss.set(st[1], parseInt(setss.get(st[1]))+1);
            setss.set(st[0], parseInt(setss.get(st[0]))+100);
        }else{
            // 
            setss.set(st[1], 1);
            setss.set(st[0], parseInt(setss.get(st[0]))+100);
        }
    }
    
    let obk= [];
    for (let [k,v] of setss) {     
       var str = {};
       str.id = k.trim();
       str.group = v; //通过数量分类
       obk.push(str);
    }
    let d3str = {};
    d3str.nodes = obk;
    d3str.links = obj;
    console.log(JSON.stringify(d3str).trim()); //按第三步说的,可以手动放置数据到可视化页面
    return JSON.stringify(d3str).trim(); //或按第四步,将数据返回至ejs模板中,直接渲染出可视化页面
 
});

注意上述代码中的注释,这里利用了类节点和标签节点出现的次数,来分辨两种节点的种类,因为绘制时类节点和标签节点都是一视同仁的被绘制。如何分辨呢?在可视化页面中有以下代码:

var circles = node.append("circle")
  .attr("r", function(d) { 
        if(d.group>=100) return d.group/100*(10.00/48.00)+1; //取整
        else return d.group+1;
    })

按照不同的次数计算步长,得到的类节点的次数一定是100的倍数,而标签节点的次数一定小于100,这个值可以设的很大,从而让两者不可能出现交集。在判断时“如果次数大于100”,那么就是类节点,取整百的好处是,归一化方便。例如上述代码需要给定节点的大小,类节点的次数统计可能是100-4800(1-48次),而标签节点的次数却是1-10(1-10次),如是,两者应绘制的一样大。这就需要归一化,只需要缩放100倍再乘比例系数即可。

最终调用

上文中hexo.extend.helper.register('getPostData', () => {})的“getPostData”即注册的函数名,在ejs(或其他)模板中直接调用即可。但由于我希望把这个可视化模块放在我的评论页或者关于页面,而这两个页面都不是渲染出来的,所以就只能采用先前第三步的做法,只构造出数据,再手动放入可视化页面。

// 在 index.ejs 内添加:
<% var arr = getPostData(); %>

所以,需要做的就是找一个渲染页面的ejs,调用下该函数即可,这里放在index.ejs里,注意由于分页可能该模板会构造很多次,所以就会重复输出很多遍JSON数据。

image.png

总结

基本上还是抓住代码执行的输入输出做文章。从待改造代码的输入找格式,然后从原代码的框架中构造出该格式的数据(输出),就像适配一样,如此便可以利用Hexo可以获得的数据,借助D3.js等可视化库,把自己的博客(知识系统)做一个梳理和呈现,从而更好的帮助自己管理维护,也给了自己二次挖掘自己知识的机会。
image.png

最终效果传送门,请用PC查看


本文作者:TZLoop
个人博客:TZLoop's Blog (zonelyn.com)
转载本文请注明原始出处。

推荐阅读
关注数
0
文章数
1
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息