可视化图布局算法浅析

前端 | 可视化图布局算法浅析.png

前言

图算法在前端领域考察的较少,一般除非是要写框架或者打包工具对依赖关系处理(DAG)会用到,前端对图算法的考察一般是比较少的,而对于可视化领域而言,图又是必不可少的一种展示方式,其中对于边和节点的展示布局方案结合美学效果会有不同的算法实现,本文旨在介绍一些常见的通用布局算法,其中的每个小的布局方案也会有不同的分支实现

分类

图片
简写 算法名称 分类 备注
grid 网格布局算法 几何布局
图片
circle 环形布局算法 几何布局
图片
concentric 同心圆布局算法 几何布局
图片
radial 辐射状布局算法 几何布局
图片
avsdf 邻接点最小度优先算法(Adjacent Vertex with Smallest Degree First) 几何布局
图片
dagre 有向无环图树布局算法(Directed Acyclic Graph and Trees) 层级布局
图片
breadthfirst 广度优先布局算法 层级布局
图片
elk Eclipse布局算法(Eclipse Layout Kernel) 层级布局
图片
klay K层布局算法(K Lay) 层级布局
图片
fcose 最快复合弹簧内置布局算法(Fast Compound Spring Embedder) 力导布局
图片
cola 约束布局(Constraint-based Layout) 力导布局
图片
cise 环形弹簧内置布局算法(Circular Spring Embedder) 力导布局
图片
elk2 Eclipse布局算法(Eclipse Layout Kernel) 力导布局
图片
euler 欧拉布局算法 力导布局
图片
spread 扩展布局算法 力导布局
图片
fruchterman Fruchterman-Reingold布局算法 力导布局
图片
combo 混合布局算法 力导布局
图片
mds 高维数据降维布局算法(Multi Dimensional Scaling) 其他布局算法
图片
random 随机布局算法 其他布局
图片

常见算法

Fruchterman-Reingold布局算法

图片
图片

Fruchterman-Reingold算法属于力导布局的一种,其本质是将之前Eades的布点算法中的基于胡克定律模型进行了改进,使用了库伦斥力并且聚焦在最近相邻节点之间的能量模型,利用模拟退火等优化策略,结合美学标准对整体进行减少线交叉及整体均匀布局,其伪码描述如下图:

图片

对于更加细节的关于FR算法的推到,可以参看这篇论文Graph Drawing by Force-directed Placement;接下来,我们来看一下前端可视化领域的一些具体实现,我们结合Antv G6中的源码看一下实现思路:

/**
* Antv的layout是专门发布了一个npm包 源码地址:https://github.com/antvis/layout
* FR算法目录位置 https://github.com/antvis/layout/blob/master/src/layout/fruchterman.ts
*/


import {
  OutNode,
  Edge,
  PointTuple,
  IndexMap,
  Point,
  FruchtermanLayoutOptions
} from "./types";
import { Base } from "./base";
import { isNumber } from "../util";

type NodeMap = {
  [key: string]: INode;
};

type INode = OutNode & {
  cluster: string;
};

const SPEED_DIVISOR = 800;

/**
 * fruchterman 布局
 */
export class FruchtermanLayout extends Base {
  /** 布局中心 */
  public center: PointTuple;

  /** 停止迭代的最大迭代数 */
  public maxIteration: number = 1000;

  /** 重力大小,影响图的紧凑程度 */
  public gravity: number = 10;

  /** 速度 */
  public speed: number = 1;

  /** 是否产生聚类力 */
  public clustering: boolean = false;

  /** 聚类力大小 */
  public clusterGravity: number = 10;

  public nodes: INode[] = [];

  public edges: Edge[] = [];

  public width: number = 300;

  public height: number = 300;

  public nodeMap: NodeMap = {};

  public nodeIdxMap: IndexMap = {};

  /** 迭代结束的回调函数 */
  public onLayoutEnd: () => void = () => {};

  constructor(options?: FruchtermanLayoutOptions) {
    super();
    this.updateCfg(options);
  }

  public getDefaultCfg() {
    return {
      maxIteration: 1000,
      gravity: 10,
      speed: 1,
      clustering: false,
      clusterGravity: 10
    };
  }

  /**
   * 执行布局
   */
  public execute() {
    const self = this;
    const nodes = self.nodes;

    if (!nodes || nodes.length === 0) {
      if (self.onLayoutEnd) self.onLayoutEnd();
      return;
    }

    if (!self.width && typeof window !== "undefined") {
      self.width = window.innerWidth;
    }
    if (!self.height && typeof window !== "undefined") {
      self.height = window.innerHeight;
    }
    if (!self.center) {
      self.center = [self.width / 2, self.height / 2];
    }
    const center = self.center;

    if (nodes.length === 1) {
      nodes[0].x = center[0];
      nodes[0].y = center[1];
      if (self.onLayoutEnd) self.onLayoutEnd();
      return;
    }
    const nodeMap: NodeMap = {};
    const nodeIdxMap: IndexMap = {};
    nodes.forEach((node, i) => {
      if (!isNumber(node.x)) node.x = Math.random() * this.width;
      if (!isNumber(node.y)) node.y = Math.random() * this.height;
      nodeMap[node.id] = node;
      nodeIdxMap[node.id] = I;
    });
    self.nodeMap = nodeMap;
    self.nodeIdxMap = nodeIdxMap;
    // layout
    return self.run();
  }

  public run() {
    const self = this;
    const nodes = self.nodes;
    const edges = self.edges;
    const maxIteration = self.maxIteration;
    const center = self.center;
    const area = self.height * self.width;
    const maxDisplace = Math.sqrt(area) / 10;
    const k2 = area / (nodes.length + 1);
    const k = Math.sqrt(k2);
    const gravity = self.gravity;
    const speed = self.speed;
    const clustering = self.clustering;
    const clusterMap: {
      [key: string]: {
        name: string | number;
        cx: number;
        cy: number;
        count: number;
      };
    } = {};
    if (clustering) {
      nodes.forEach(n => {
        if (clusterMap[n.cluster] === undefined) {
          const cluster = {
            name: n.cluster,
            cx: 0,
            cy: 0,
            count: 0
          };
          clusterMap[n.cluster] = cluster;
        }
        const c = clusterMap[n.cluster];
        if (isNumber(n.x)) {
          c.cx += n.x;
        }
        if (isNumber(n.y)) {
          c.cy += n.y;
        }
        c.count++;
      });
      for (const key in clusterMap) {
        clusterMap[key].cx /= clusterMap[key].count;
        clusterMap[key].cy /= clusterMap[key].count;
      }
    }
    for (let i = 0; i < maxIteration; i++) {
      const displacements: Point[] = [];
      nodes.forEach((_, j) => {
        displacements[j] = { x: 0, y: 0 };
      });
      self.applyCalculate(nodes, edges, displacements, k, k2);

      // gravity for clusters
      if (clustering) {
        const clusterGravity = self.clusterGravity || gravity;
        nodes.forEach((n, j) => {
          if (!isNumber(n.x) || !isNumber(n.y)) return;
          const c = clusterMap[n.cluster];
          const distLength = Math.sqrt(
            (n.x - c.cx) * (n.x - c.cx) + (n.y - c.cy) * (n.y - c.cy)
          );
          const gravityForce = k * clusterGravity;
          displacements[j].x -= (gravityForce * (n.x - c.cx)) / distLength;
          displacements[j].y -= (gravityForce * (n.y - c.cy)) / distLength;
        });

        for (const key in clusterMap) {
          clusterMap[key].cx = 0;
          clusterMap[key].cy = 0;
          clusterMap[key].count = 0;
        }

        nodes.forEach(n => {
          const c = clusterMap[n.cluster];
          if (isNumber(n.x)) {
            c.cx += n.x;
          }
          if (isNumber(n.y)) {
            c.cy += n.y;
          }
          c.count++;
        });
        for (const key in clusterMap) {
          clusterMap[key].cx /= clusterMap[key].count;
          clusterMap[key].cy /= clusterMap[key].count;
        }
      }

      // gravity
      nodes.forEach((n, j) => {
        if (!isNumber(n.x) || !isNumber(n.y)) return;
        const gravityForce = 0.01 * k * gravity;
        displacements[j].x -= gravityForce * (n.x - center[0]);
        displacements[j].y -= gravityForce * (n.y - center[1]);
      });

      // move
      nodes.forEach((n, j) => {
        if (!isNumber(n.x) || !isNumber(n.y)) return;
        const distLength = Math.sqrt(
          displacements[j].x * displacements[j].x +
            displacements[j].y * displacements[j].y
        );
        if (distLength > 0) {
          // && !n.isFixed()
          const limitedDist = Math.min(
            maxDisplace * (speed / SPEED_DIVISOR),
            distLength
          );
          n.x += (displacements[j].x / distLength) * limitedDist;
          n.y += (displacements[j].y / distLength) * limitedDist;
        }
      });
    }

    if (self.onLayoutEnd) self.onLayoutEnd();

    return {
      nodes,
      edges
    };
  }

  private applyCalculate(
    nodes: INode[],
    edges: Edge[],
    displacements: Point[],
    k: number,
    k2: number
  ) {
    const self = this;
    self.calRepulsive(nodes, displacements, k2);
    self.calAttractive(edges, displacements, k);
  }

  // 计算斥力
  private calRepulsive(nodes: INode[], displacements: Point[], k2: number) {
    nodes.forEach((v, i) => {
      displacements[i] = { x: 0, y: 0 };
      nodes.forEach((u, j) => {
        if (i === j) {
          return;
        }
        if (
          !isNumber(v.x) ||
          !isNumber(u.x) ||
          !isNumber(v.y) ||
          !isNumber(u.y)
        )
          return;
        let vecX = v.x - u.x;
        let vecY = v.y - u.y;
        let vecLengthSqr = vecX * vecX + vecY * vecY;
        if (vecLengthSqr === 0) {
          vecLengthSqr = 1;
          const sign = i > j ? 1 : -1;
          vecX = 0.01 * sign;
          vecY = 0.01 * sign;
        }
        // 核心计算项 C常数值
        const common = k2 / vecLengthSqr;
        displacements[i].x += vecX * common;
        displacements[i].y += vecY * common;
      });
    });
  }

  // 计算引力
  private calAttractive(edges: Edge[], displacements: Point[], k: number) {
    edges.forEach(e => {
      if (!e.source || !e.target) return;
      const uIndex = this.nodeIdxMap[e.source];
      const vIndex = this.nodeIdxMap[e.target];
      if (uIndex === vIndex) {
        return;
      }
      const u = this.nodeMap[e.source];
      const v = this.nodeMap[e.target];
      if (!isNumber(v.x) || !isNumber(u.x) || !isNumber(v.y) || !isNumber(u.y))
        return;
      const vecX = v.x - u.x;
      const vecY = v.y - u.y;
      const vecLength = Math.sqrt(vecX * vecX + vecY * vecY);
      const common = (vecLength * vecLength) / k;
      displacements[vIndex].x -= (vecX / vecLength) * common;
      displacements[vIndex].y -= (vecY / vecLength) * common;
      displacements[uIndex].x += (vecX / vecLength) * common;
      displacements[uIndex].y += (vecY / vecLength) * common;
    });
  }

  public getType() {
    return "fruchterman";
  }
}

Grid布局算法

在dom中布局,我们最常想到的就是网格布局,在最早的没有div的时代,都是通过table进行布局的,这里图的布局也是最容易想到的一种布局方式,虽然简单,但我们也可以看一下对应的实现思路,我们看一下在Cytoscape中的实现方案:

// grid布局目录位置 https://github.com/cytoscape/cytoscape.js/blob/unstable/src/extensions/layout/grid.js

function GridLayout( options ){
  this.options = util.extend( {}, defaults, options );
}

GridLayout.prototype.run = function(){
  let params = this.options;
  let options = params;

  let cy = params.cy;
  let eles = options.eles;
  let nodes = eles.nodes().not( ':parent' );

  if( options.sort ){
    nodes = nodes.sort( options.sort );
  }

  let bb = math.makeBoundingBox( options.boundingBox ? options.boundingBox : {
    x1: 0, y1: 0, w: cy.width(), h: cy.height()
  } );

  if( bb.h === 0 || bb.w === 0 ){
    eles.nodes().layoutPositions( this, options, function( ele ){
      return { x: bb.x1, y: bb.y1 };
    } );

  } else {

    // width/height * splits^2 = cells where splits is number of times to split width
    let cells = nodes.size();
    let splits = Math.sqrt( cells * bb.h / bb.w );
    let rows = Math.round( splits );
    let cols = Math.round( bb.w / bb.h * splits );

    let small = function( val ){
      if( val == null ){
        return Math.min( rows, cols );
      } else {
        let min = Math.min( rows, cols );
        if( min == rows ){
          rows = val;
        } else {
          cols = val;
        }
      }
    };

    let large = function( val ){
      if( val == null ){
        return Math.max( rows, cols );
      } else {
        let max = Math.max( rows, cols );
        if( max == rows ){
          rows = val;
        } else {
          cols = val;
        }
      }
    };

    let oRows = options.rows;
    let oCols = options.cols != null ? options.cols : options.columns;

    // if rows or columns were set in options, use those values
    if( oRows != null && oCols != null ){
      rows = oRows;
      cols = oCols;
    } else if( oRows != null && oCols == null ){
      rows = oRows;
      cols = Math.ceil( cells / rows );
    } else if( oRows == null && oCols != null ){
      cols = oCols;
      rows = Math.ceil( cells / cols );
    }

    // otherwise use the automatic values and adjust accordingly

    // if rounding was up, see if we can reduce rows or columns
    else if( cols * rows > cells ){
      let sm = small();
      let lg = large();

      // reducing the small side takes away the most cells, so try it first
      if( (sm - 1) * lg >= cells ){
        small( sm - 1 );
      } else if( (lg - 1) * sm >= cells ){
        large( lg - 1 );
      }
    } else {

      // if rounding was too low, add rows or columns
      while( cols * rows < cells ){
        let sm = small();
        let lg = large();

        // try to add to larger side first (adds less in multiplication)
        if( (lg + 1) * sm >= cells ){
          large( lg + 1 );
        } else {
          small( sm + 1 );
        }
      }
    }

    let cellWidth = bb.w / cols;
    let cellHeight = bb.h / rows;

    if( options.condense ){
      cellWidth = 0;
      cellHeight = 0;
    }

    if( options.avoidOverlap ){
      for( let i = 0; i < nodes.length; i++ ){
        let node = nodes[ I ];
        let pos = node._private.position;

        if( pos.x == null || pos.y == null ){ // for bb
          pos.x = 0;
          pos.y = 0;
        }

        let nbb = node.layoutDimensions( options );
        let p = options.avoidOverlapPadding;

        let w = nbb.w + p;
        let h = nbb.h + p;

        cellWidth = Math.max( cellWidth, w );
        cellHeight = Math.max( cellHeight, h );
      }
    }

    let cellUsed = {}; // e.g. 'c-0-2' => true

    let used = function( row, col ){
      return cellUsed[ 'c-' + row + '-' + col ] ? true : false;
    };

    let use = function( row, col ){
      cellUsed[ 'c-' + row + '-' + col ] = true;
    };

    // to keep track of current cell position
    let row = 0;
    let col = 0;
    let moveToNextCell = function(){
      col++;
      if( col >= cols ){
        col = 0;
        row++;
      }
    };

    // get a cache of all the manual positions
    let id2manPos = {};
    for( let i = 0; i < nodes.length; i++ ){
      let node = nodes[ I ];
      let rcPos = options.position( node );

      if( rcPos && (rcPos.row !== undefined || rcPos.col !== undefined) ){ // must have at least row or col def'd
        let pos = {
          row: rcPos.row,
          col: rcPos.col
        };

        if( pos.col === undefined ){ // find unused col
          pos.col = 0;

          while( used( pos.row, pos.col ) ){
            pos.col++;
          }
        } else if( pos.row === undefined ){ // find unused row
          pos.row = 0;

          while( used( pos.row, pos.col ) ){
            pos.row++;
          }
        }

        id2manPos[ node.id() ] = pos;
        use( pos.row, pos.col );
      }
    }

    let getPos = function( element, I ){
      let x, y;

      if( element.locked() || element.isParent() ){
        return false;
      }

      // see if we have a manual position set
      let rcPos = id2manPos[ element.id() ];
      if( rcPos ){
        x = rcPos.col * cellWidth + cellWidth / 2 + bb.x1;
        y = rcPos.row * cellHeight + cellHeight / 2 + bb.y1;

      } else { // otherwise set automatically

        while( used( row, col ) ){
          moveToNextCell();
        }

        x = col * cellWidth + cellWidth / 2 + bb.x1;
        y = row * cellHeight + cellHeight / 2 + bb.y1;
        use( row, col );

        moveToNextCell();
      }

      return { x: x, y: y };

    };

    nodes.layoutPositions( this, options, getPos );
  }

  return this; // chaining

};

export default GridLayout;

MDS算法

图片

MDS是Multidimensional Scaling的简称,即为高维数据降维算法,其是一种力导算法高维数据下的稳定下布局的优化,避免数据超载而导致的整体的布局不稳定,上图中方程式经过数学推导化简后(ps:对于具体推导感兴趣的同学可以看这篇文章图布局算法之Stress Majorization),其伪码描述如下:

图片

接下来,我们来看一下前端的具体实现,来看一下Antv G6中的实现方案:

/**
* Antv的layout是专门发布了一个npm包 源码地址:https://github.com/antvis/layout
* MDS算法目录位置 https://github.com/antvis/layout/blob/master/src/layout/mds.ts
*/

// ml-matrix是机器学习相关的一些矩阵操作
import { Matrix as MLMatrix, SingularValueDecomposition } from "ml-matrix";
import { PointTuple, OutNode, Edge, Matrix, MDSLayoutOptions } from "./types";
import { floydWarshall, getAdjMatrix, scaleMatrix } from "../util";
import { Base } from "./base";

/**
 * mds 布局
 */
export class MDSLayout extends Base {
  /** 布局中心 */
  public center: PointTuple = [0, 0];

  /** 边长度 */
  public linkDistance: number = 50;

  private scaledDistances: Matrix[];

  public nodes: OutNode[] = [];

  public edges: Edge[] = [];

  /** 迭代结束的回调函数 */
  public onLayoutEnd: () => void = () => {};

  constructor(options?: MDSLayoutOptions) {
    super();
    this.updateCfg(options);
  }

  public getDefaultCfg() {
    return {
      center: [0, 0],
      linkDistance: 50
    };
  }

  /**
   * 执行布局
   */
  public execute() {
    const self = this;
    const { nodes, edges = [] } = self;
    const center = self.center;
    if (!nodes || nodes.length === 0) {
      if (self.onLayoutEnd) self.onLayoutEnd();
      return;
    }
    if (nodes.length === 1) {
      nodes[0].x = center[0];
      nodes[0].y = center[1];
      if (self.onLayoutEnd) self.onLayoutEnd();
      return;
    }
    const linkDistance = self.linkDistance;
    // the graph-theoretic distance (shortest path distance) matrix
    const adjMatrix = getAdjMatrix({ nodes, edges }, false);
    const distances = floydWarshall(adjMatrix);
    self.handleInfinity(distances);

    // scale the ideal edge length acoording to linkDistance
    const scaledD = scaleMatrix(distances, linkDistance);
    self.scaledDistances = scaledD;

    // get positions by MDS
    const positions = self.runMDS();
    self.positions = positions;
    positions.forEach((p: number[], i: number) => {
      nodes[i].x = p[0] + center[0];
      nodes[i].y = p[1] + center[1];
    });

    if (self.onLayoutEnd) self.onLayoutEnd();

    return {
      nodes,
      edges
    };
  }

  /**
   * mds 算法
   * @return {array} positions 计算后的节点位置数组
   */
  public runMDS(): PointTuple[] {
    const self = this;
    const dimension = 2;
    const distances = self.scaledDistances;

    // square distances
    const M = MLMatrix.mul(MLMatrix.pow(distances, 2), -0.5);

    // double centre the rows/columns
    const rowMeans = M.mean("row");
    const colMeans = M.mean("column");
    const totalMean = M.mean();
    M.add(totalMean)
      .subRowVector(rowMeans)
      .subColumnVector(colMeans);

    // take the SVD of the double centred matrix, and return the
    // points from it
    const ret = new SingularValueDecomposition(M);
    const eigenValues = MLMatrix.sqrt(ret.diagonalMatrix).diagonal();
    return ret.leftSingularVectors.toJSON().map((row: number[]) => {
      return MLMatrix.mul([row], [eigenValues])
        .toJSON()[0]
        .splice(0, dimension) as PointTuple;
    });
  }

  public handleInfinity(distances: Matrix[]) {
    let maxDistance = -999999;
    distances.forEach(row => {
      row.forEach(value => {
        if (value === Infinity) {
          return;
        }
        if (maxDistance < value) {
          maxDistance = value;
        }
      });
    });
    distances.forEach((row, i) => {
      row.forEach((value, j) => {
        if (value === Infinity) {
          distances[i][j] = maxDistance;
        }
      });
    });
  }

  public getType() {
    return "mds";
  }
}

总结

可视化图布局是可视化领域一个比较精深的方向,其设计了美学理念、机器学习、数据分析等相关知识,对于智能布局预测,可以结合机器学习等人工智能方法进行处理,业界常见的比如对于OpenOrd的一些大规模图布局的边切优化等,具体感兴趣的同学可以参看这篇文章OpenOrd-面向大规模图布局的开源算法-研读;对于前端智能化与可视化结合的方面,可以将tensorflow与可视化图布局进行拓展,具体可以看一下G6的图布局预测方案G6 智能布局预测,其大概实现思路是借助tensorflow,对于卷积层和池化层的处理以及相关操作。综上,可视化图布局领域的拓展结合了前端智能化与前端数据可视化两大前端方向,由此可见,前端的七大发展方向并非是割裂开来的,他们之间相互影响、相互借鉴,对于交叉领域深入研究或许能有不同的启发!

参考

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

推荐阅读更多精彩内容