UP | HOME

Date: [2023-08-23 Wed]

My Digital Garden Setup

Table of Contents

1. Graph

1.1. Open the org-roam.db and export graph to json

< Collapse code block> Expand code block
import networkx as nx
import pathlib
import sqlite3


def to_rellink(inp: str) -> str:
    return pathlib.Path(inp).stem


def build_graph() -> nx.DiGraph:
    """Build a graph from the org-roam database."""
    graph = nx.DiGraph()
    home = pathlib.Path.home()
    conn = sqlite3.connect(home / "org/dbs/darwin/" / "org-roam.db")

    # Query all nodes first
    nodes = conn.execute("SELECT file, id, title FROM nodes WHERE level = 0 and  file not like '%.gpg%' and properties not like '%private%' and properties not like '%PRIVATE%' and properties not like '%personal%' and properties not like '%PERSONAL%';") 
    # A double JOIN to get all nodes that are connected by a link
    links = conn.execute("SELECT n1.id, nodes.id FROM ((nodes AS n1) "
                         "JOIN links ON n1.id = links.source) "
                         "JOIN (nodes AS n2) ON links.dest = nodes.id "
                         "WHERE links.type = '\"id\"';")
    # Populate the graph
    graph.add_nodes_from((n[1], {
        "label": n[2].strip("\""),
        "tooltip": n[2].strip("\""),
        "lnk": to_rellink(n[0]).lower(),
        "id": n[1].strip("\"")
    }) for n in  nodes)
    graph.add_edges_from(n for n in links if n[0] in graph.nodes and n[1] in graph.nodes)
    conn.close()
    return graph
< Collapse code block> Expand code block
import itertools
import json
import sys

import networkx as nx
import networkx.algorithms.link_analysis.pagerank_alg as pag
import networkx.algorithms.community as com
from networkx.drawing.nx_pydot import read_dot
from networkx.readwrite import json_graph

N_COM = 7  # Desired number of communities
N_MISSING = 50  # Number of predicted missing links
MAX_NODES = 2000  # Number of nodes in the final graph 

def compute_centrality(dot_graph: nx.DiGraph) -> None:
    """Add a `centrality` attribute to each node with its PageRank score.
    """
    simp_graph = nx.Graph(dot_graph)
    central = pag.pagerank(simp_graph)
    min_cent = min(central.values())
    central = {i: central[i] - min_cent for i in central}
    max_cent = max(central.values())
    central = {i: central[i] / max_cent for i in central}
    nx.set_node_attributes(dot_graph, central, "centrality")
    sorted_cent = sorted(dot_graph, key=lambda x: dot_graph.nodes[x]["centrality"])
    for n in sorted_cent[:-MAX_NODES]:
        dot_graph.remove_node(n)


def compute_communities(dot_graph: nx.DiGraph, n_com: int) -> None:
    """Add a `communityLabel` attribute to each node according to their
    computed community.
    """
    simp_graph = nx.Graph(dot_graph)
    communities = com.girvan_newman(simp_graph)
    labels = [tuple(sorted(c) for c in unities) for unities in
              itertools.islice(communities, n_com - 1, n_com)][0]
    label_dict = {l_key: i for i in range(len(labels)) for l_key in labels[i]}
    nx.set_node_attributes(dot_graph, label_dict, "communityLabel")

def read_and_dump():
    sys.stderr.write("Reading graph...")
    DOT_GRAPH = build_graph()
    compute_centrality(DOT_GRAPH)
    compute_communities(DOT_GRAPH, N_COM)
    sys.stderr.write("Done\n") 
    JS_GRAPH = json_graph.node_link_data(DOT_GRAPH)
    #sys.stdout.write(json.dumps(JS_GRAPH))
    return json.dumps(JS_GRAPH)

read_and_dump()

./blog/node-graph.json

1.2. Use D3.js to render the graph

In addition to the code from https://hugocisneros.com/blog/my-org-roam-notes-workflow/, I added zoom, pan and changed some physics parameters

Further resources to add zoom, pan:

Physics

< Collapse code block> Expand code block
let graph_name = "../node-graph.json";

function handleHover() {
  // Make nodes interactive to hovering
  handleMouseOver = (d, i) => {
    nde = d3.select(d.currentTarget);
    // gray color and increased size
    nde.attr("fill", "#999")
      .attr("r", nde.attr("r") * 1.4);

    // display text element
    d3.selectAll("text")
      .filter('#' + CSS.escape(d.currentTarget.id))
      .style("font-size", (view.fontZoomSize / transform.k) + 'px')
      .style("display", "block")


    d3.selectAll("line")
      .filter((l, _) =>
        l.source.index == i.index ||
          l.target.index == i.index)
      .attr("stroke-width", 8);
  };

  handleMouseOut = (d, _) => {
    nde = d3.select(d.currentTarget);
    nde.attr("fill", nodeColor)
      .attr("r", nde.attr("r") / 1.4);

    // revert to normal thickness for all lines except the connecting lines
    d3.selectAll("line")
      .attr("stroke-width", 1);

    const el = d3.selectAll("text")
          .filter('#' + CSS.escape(d.currentTarget.id))
          .style("font-size", view.fontSize)
    if (transform.k < view.labelsToggle) {
      el.style("display", "none")
    }

  };

  return { handleMouseOver, handleMouseOut }
}

let transform = d3.zoomIdentity;
function drag(simulation) {
  function dragsubject(event) {
    const node = simulation.find(transform.invertX(event.x), transform.invertY(event.y));
    node.x = transform.applyX(node.x);
    node.y = transform.applyY(node.y);
    return node;
  }

  function dragstarted(event) {
    if (!event.active) simulation.alphaTarget(0.7).restart();
    event.subject.fx = transform.invertX(event.subject.x);
    event.subject.fy = transform.invertY(event.subject.y);
  }

  function dragged(event) {
    event.subject.fx = transform.invertX(event.x);
    event.subject.fy = transform.invertY(event.y);
  }

  function dragended(event) {
    if (!event.active) simulation.alphaTarget(0);
    event.subject.fx = null;
    event.subject.fy = null;
  }

  return d3.drag()
    .subject(dragsubject)
    .on("start", dragstarted)
    .on("drag", dragged)
    .on("end", dragended);
};


function handleZoom(svg, els) {
  const zoomed = (e) => {
    els.forEach(el => el.attr('transform', e.transform))
    transform = e.transform;
    if (transform.k > view.labelsToggle) {
      d3.selectAll("text").style("display", "block");
    } else {
      d3.selectAll("text").style("display", "none");
    }
  }

  const zoom = d3.zoom()
        .on('zoom', zoomed)

  svg.call(zoom)

  const initial = new d3.ZoomTransform(0.15, 800, 460)
  svg.call(zoom.transform, initial);
}

const physics = {
  gravity: 0.05,
  alpha: 0.7,
  velocityDecay: 0.25,
  linkForce: 0.2,
  charge: -1000,
  collisionStrength: 1.5
}

const view = {
  height: 1100,
  width: 1600,
  fontSize: "25px",
  fontZoomSize: 50,
  labelsToggle: 0.5 // at 0.5x zoom
}

function draw_graph(graph_name) {
  return d3.json(graph_name).then(function (data) {
    // Radius function for nodes. Node radius are function of centrality
    scale = 1;
    radius = d => {
      if (!d.radius) {
        d.radius = 2 * (11 + 24 * Math.pow(d.centrality, 4 / 5));
      }
      return d.radius;
    };
    color = "#ffffff";

    // Number of colors is the number of clusters (given by communityLabel)
    num_colors = Math.max(...data.nodes.map(d => d.communityLabel)) + 1;

    angleArr = [...Array(num_colors).keys()].map(x => 2 * Math.PI * x / num_colors);
    // Cluster centers around an circle
    centersx = angleArr.map(x => Math.cos(Math.PI + x));
    centersy = angleArr.map(x => Math.sin(Math.PI + x));
    // Color palette
    nodeColors = [
      '#C98914',
      '#C55F1A',
      '#4189AD',
      '#007500',
      '#968674',
      '#5E998A',
      "#363ea9",
    ];
    // Color function just maps cluster to color palette
    nodeColor = d => {
      return nodeColors[d.communityLabel % nodeColors.length];
    };


    // Graph data
    const links = data.links.map(d => Object.create(d));
    const nodes = data.nodes.map(d => Object.create(d));

    // Force simulation for the graph
    simulation = d3.forceSimulation(nodes)
      .alpha(physics.alpha)
      .velocityDecay(physics.velocityDecay)
      .force("link", d3.forceLink(links).id(d => d.id).strength(physics.linkForce))
      .force("charge", d3.forceManyBody()
             .strength(physics.charge))
      .force('collision',
             d3.forceCollide().radius(d => radius(d) * 1.2).strength(physics.collisionStrength))
      .force('x', d3.forceX()
             .strength(physics.gravity))
      .force('y', d3.forceY()
             .strength(physics.gravity))

    // Create all the graph elements
    const svg = d3.select("svg#note-graph")
          .attr('max-width', '60%')
          .attr("viewBox", [0, 0, view.width, view.height])

    const link = svg.append("g")
          .attr("stroke", "#000")
          .attr("stroke-opacity", 1)
          .selectAll("line")
          .data(links)
          .join("line")
          .attr("stroke-width", 1);

    const { handleMouseOver, handleMouseOut } = handleHover()

    const node = svg.append("g")
          .selectAll("circle")
          .data(nodes)
          .join("a")
          .attr("xlink:href", d => {
            return "/braindump/" + d.lnk + '.html';
          })
          .attr("id", d => "circle_" + d.lnk)
          .append("circle")
          .attr("id", d => d.id.toLowerCase())
          .attr("r", radius)
          .attr("fill", nodeColor)
          .attr("stroke", "#000")
          .attr("stroke-width", 1)
          .on("mouseover", handleMouseOver)
          .on("mouseout", handleMouseOut)
          .on('zoom', handleZoom)
          .call(drag(simulation))

    node.append("title")
      .text(d => d.label.replace(/"/g, ''));


    // Nodes have a label that is visible on hover
    // They have two layers a rectangle "background" and the text on top
    const label = svg.append("g")
          .selectAll("text")
          .data(nodes)
          .join("g");
    const label_background = label.append("text")
          .style("font-size", view.fontSize)
          .text(function (d) { return "  " + d.label.replace(/"/g, '') + "  "; })
          .attr("dy", -25)
          .attr("id", d => d.id.toLowerCase())
          .attr("class", "node_label")
          .style("display", "none")
          .style("pointer-events", "none")
          .style("alignment-baseline", "middle")
          .attr("filter", "url(#solid)");

    const label_text = label.append("text")
          .style("fill", "#222")
          .style("font-size", view.fontSize)
          .text(function (d) { return "  " + d.label.replace(/"/g, '') + "  "; })
          .attr("dy", -25)
          .attr("id", d => d.id.toLowerCase())
          .attr("class", "node_label")
          .style("display", "none")
          .style("pointer-events", "none")
          .style("alignment-baseline", "middle");

    handleZoom(svg, [node, link, label]);

    // Run the simulation
    simulation.on("tick", () => {
      link.attr("x1", d => d.source.x)
        .attr("y1", d => d.source.y)
        .attr("x2", d => d.target.x)
        .attr("y2", d => d.target.y);

      node.attr("cx", d => d.x)
        .attr("cy", d => d.y);

      label_text.attr("x", d => d.x)
        .attr("y", d => d.y);

      label_background.attr("x", d => d.x)
        .attr("y", d => d.y);
    });
  });
}

You can send your feedback, queries here