Terence Eden’s Blog<p><strong>Graphing the connections between my blog posts</strong></p><p><a href="https://shkspr.mobi/blog/2025/01/graphing-the-connections-between-my-blog-posts/" rel="nofollow noopener" translate="no" target="_blank"><span class="invisible">https://</span><span class="ellipsis">shkspr.mobi/blog/2025/01/graph</span><span class="invisible">ing-the-connections-between-my-blog-posts/</span></a></p><p>I love ripping off good ideas from other people's blogs. I was reading <a href="https://stuff.graves.cl/posts/2024-03-05_20_41-book-review-small-gods-by-terry-pratchett" rel="nofollow noopener" target="_blank">Alvaro Graves-Fuenzalida's blog</a> when I saw this nifty little force-directed graph:</p><p></p><p>When zoomed in, it shows the relation between posts and tags.</p><p></p><p>In this case, I can see that the posts about Small Gods and Pyramids both share the tags of Discworld, Fantasy, and Book Review. But only Small Gods has the tag of Religion.</p><p>Isn't that cool! It is a native feature of <a href="https://quartz.jzhao.xyz/features/graph-view" rel="nofollow noopener" target="_blank">Quartz's GraphView</a>. How can I build something like that for my WordPress blog?</p><p><strong>Aim</strong></p><p>Create an interactive graph which shows the relationship between a post, its links, and their tags.</p><p>It will end up looking something like this:</p><p></p><p>You can <a href="https://gitlab.com/edent/blog-theme/-/blob/master/includes/graph.php" rel="nofollow noopener" target="_blank">get the code</a> or follow along to see how it works.</p><p>This is a multi-stage process. Let's begin!</p><p><strong>What We Need</strong></p><p>When on a single Post, we need the following:</p><ul><li>The tags assigned to that Post.</li><li>Internal links back to that Post.</li><li>Internal links from that Post.</li><li>The tags assigned to links to and from that Post.</li></ul><p><strong>Tags assigned to that Post.</strong></p><p>This is pretty easy! Using the <a href="https://developer.wordpress.org/reference/functions/get_the_tag_list/" rel="nofollow noopener" target="_blank"><code>get_the_tag_list()</code> function</a> we can, unsurprisingly, get all the tags associated with a post.</p><pre><code>$post_tags_text = get_the_tag_list( "", ",", $ID );$post_tags_array = explode( "," , $post_tags_text );</code></pre><p>That just gets the list of tag names. If we want the tag IDs as well, we need to use <a href="https://developer.wordpress.org/reference/functions/get_the_tags/" rel="nofollow noopener" target="_blank">the <code>get_the_tags()</code> function</a>.</p><pre><code>$post_tags = get_the_tags($ID);$tags = array();foreach($post_tags as $tag) { $tags[$tag->term_id] = $tag->name; }</code></pre><p><strong>Backlinks</strong></p><p>Internal links back to the Post is slightly trickier. WordPress doesn't save relational information like that. Instead, we get the Post's URl and <a href="https://shkspr.mobi/blog/2023/10/displaying-internal-linkbacks-on-wordpress/" rel="nofollow noopener" target="_blank">search for that in the database</a>. Then we get the post IDs of all the posts which contain that string.</p><pre><code>// Get all the posts which link to this one, oldest first$the_query = new WP_Query( array( 's' => $search_url, 'post_type' => 'post', "posts_per_page" => "-1", "order" => "ASC" ));// Nothing to do if there are no inbound linksif ( !$the_query->have_posts() ) { return;}</code></pre><p><strong>Backlinks' Tags</strong></p><p>Once we have an array of posts which link back here, we can get their tags as above:</p><pre><code>// Loop through the postswhile ( $the_query->have_posts() ) { // Set it up $the_query->the_post(); $id = get_the_ID(); $title = esc_html( get_the_title() ); $url = get_the_permalink(); $backlink_tags_text = get_the_tag_list( "", ",", $ID ); $backlink_tags_array = explode( "," , $backlink_tags_text );}</code></pre><p><strong>Links from the Post</strong></p><p>Again, WordPress's lack of relational links is a weakness. In order to get internal links, we need to:</p><ol><li>Render the HTML using all the filters</li><li>Search for all <code><a href="…"></code></li><li>Extract the ones which start with the blog's domain</li><li>Get those posts' IDs.</li></ol><p>Rendering the content into HTML is done with:</p><pre><code>$content = apply_filters( "the_content", get_the_content( null, false, $ID ) );</code></pre><p>Searching for links is slightly more complex. The easiest way is to load the HTML into a DOMDocument, then extract all the anchors. All my blog posts start <code>/blog/YYYY</code> so I can avoid selecting links to tags, uploaded files, or other things. Your blog may be different.</p><pre><code>$dom = new DOMDocument();libxml_use_internal_errors( true ); // Suppress warnings from malformed HTML$dom->loadHTML( $content );libxml_clear_errors();$links = [];foreach ( $dom->getElementsByTagName( "a" ) as $anchor ) { $href = $anchor->getAttribute( "href" ); if (preg_match('/^https:\/\/shkspr\.mobi\/blog\/\d{4}$/', $href)) { $links[] = $href; }}</code></pre><p>The ID of each post can be found with <a href="https://developer.wordpress.org/reference/functions/url_to_postid/" rel="nofollow noopener" target="_blank">the <code>url_to_postid()</code> function</a>. That means we can re-use the earlier code to see what tags those posts have.</p><p><strong>Building a graph</strong></p><p>OK, so we have all our constituent parts. Let's build a graph!</p><p>Graphs consist of nodes (posts and tags) and edges (links between them). The exact format of the graph is going to depend on the graph library we use.</p><p>I've decided to use <a href="https://github.com/d3/d3-force?tab=readme-ov-file" rel="nofollow noopener" target="_blank">D3.js's Force Graph</a> as it is relatively simple and produces a reasonably good looking interactive SVG.</p><p>Imagine there are two blog posts and two hashtags.</p><pre><code>const nodes = [ { id: 1, label: "Blog Post 1", url: "https://example.com/post/1", group: "post" }, { id: 2, label: "Blog Post 2", url: "https://example.com/post/2", group: "post" }, { id: 3, label: "hashtag", url: "https://example.com/tag/3", group: "tag" }, { id: 4, label: "anotherHashtag", url: "https://example.com/tag/4", group: "tag" },];</code></pre><ul><li>Blog Post 1 links to Blog Post 2.</li><li>Blog Post 1 has a #hashtag.</li><li>Both 1 & 2 share #anotherHashtag.</li></ul><pre><code>const links = [ { source: 1, target: 2 }, { source: 3, target: 1 }, { source: 4, target: 1 }, { source: 4, target: 2 },];</code></pre><p>Here's how to create a list of nodes and their links. You will need to edit it for your own blog's peculiarities.</p><pre><code><?php // Load WordPress environmentrequire_once( "wp-load.php" );// Set up arrays for nodes and links$nodes = array();$links = array();// ID of the Post$main_post_id = 12345;// Get the Post's details$main_post_url = get_permalink( $main_post_id );$main_post_title = get_the_title( $main_post_id );// Function to add new nodesfunction add_item_to_nodes( &$nodes, $id, $label, $url, $group ) { $nodes[] = [ "id" => $id, "label" => $label, "url" => $url, "group" => $group ];}// Function to add new relationshipsfunction add_relationship( &$links, $source, $target ) { $links[] = [ "source" => $source, "target" => $target ];}// Add Post to the nodesadd_item_to_nodes( $nodes, $main_post_id, $main_post_title, $main_post_url, "post" );// Get the tags of the Post$main_post_tags = get_the_tags( $main_post_id );// Add the tags as nodes, and create links to main Postforeach( $main_post_tags as $tag ) { $id = $tag->term_id; $name = $tag->name; // Add the node add_item_to_nodes( $nodes, $id, $name, "https://shkspr.mobi/blog/tag/" . $name, "tag" ); // Add the relationship add_relationship( $links, $id, $main_post_id );}// Get all the posts which link to this one, oldest first$the_query = new WP_Query( array( 's' => $main_post_url, 'post_type' => 'post', "posts_per_page" => "-1", "order" => "ASC" ));// Nothing to do if there are no inbound linksif ( $the_query->have_posts() ) { // Loop through the posts while ( $the_query->have_posts() ) { // Set up the query $the_query->the_post(); $post_id = get_the_ID(); $title = esc_html( get_the_title() ); $url = get_the_permalink(); // Add the node add_item_to_nodes( $nodes, $post_id, $title, $url, "post" ); // Add the relationship add_relationship( $links, $post_id, $main_post_id ); // Get the tags of the Post $post_tags = get_the_tags( $post_id ); // Add the tags as nodes, and create links to main Post foreach($post_tags as $tag) { $id = $tag->term_id; $name = $tag->name; // Add the node add_item_to_nodes( $nodes, $id, $name, "https://shkspr.mobi/blog/tag/" . $name, "tag" ); // Add the relationship add_relationship( $links, $id, $post_id ); } }}// Get all the internal links from this post// Render the post as HTML$content = apply_filters( "the_content", get_the_content( null, false, $ID ) );// Load it into HTML$dom = new DOMDocument();libxml_use_internal_errors( true );$dom->loadHTML( $content );libxml_clear_errors();// Get any <a href="…" which starts with https://shkspr.mobi/blog/$internal_links = [];foreach ( $dom->getElementsByTagName( "a" ) as $anchor ) { $href = $anchor->getAttribute( "href" ); if (preg_match('/^https:\/\/shkspr\.mobi\/blog\/\d{4}$/', $href)) { $internal_links[] = $href; }}// Loop through the internal links, get their hashtagsforeach ( $internal_links as $url ) { $post_id = url_to_postid( $url ); // Get the Post's details $post_title = get_the_title( $id ); // Add the node add_item_to_nodes( $nodes, $post_id, $post_title, $url, "post" ); // Add the relationship add_relationship($links, $main_post_id, $post_id ); // Get the tags of the Post $post_tags = get_the_tags( $post_id ); // Add the tags as nodes, and create links to main Post foreach( $post_tags as $tag ) { $id = $tag->term_id; $name = $tag->name; // Add the node add_item_to_nodes( $nodes, $id, $name, "https://shkspr.mobi/blog/tag/" . $name, "tag" ); // Add the relationship add_relationship( $links, $id, $post_id ); }}// Deduplicate the nodes and links$nodes_unique = array_unique( $nodes, SORT_REGULAR );$links_unique = array_unique( $links, SORT_REGULAR );// Put them in the keyless format that D3 expects$nodes_output = array();$links_output = array();foreach ( $nodes_unique as $node ) { $nodes_output[] = $node;}foreach ( $links_unique as $link ) { $links_output[] = $link;}// Return the JSONecho json_encode( $nodes_output, JSON_PRETTY_PRINT );echo "\n";echo json_encode( $links_output, JSON_PRETTY_PRINT );</code></pre><p><strong>Creating a Force Directed SVG</strong></p><p>Once the data are spat out, you can include them in a web-page. Here's a basic example:</p><pre><code><!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Force Directed Graph</title> <script src="https://d3js.org/d3.v7.min.js"></script> </head> <body> <svg width="800" height="600"> <defs> <marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto" fill="#999"> <path d="M0,0 L10,3.5 L0,7 Z"></path> </marker> </defs> </svg> <script></code></pre><pre><code> const nodes = []; const links = []; const width = 800; const height = 600; const svg = d3.select("svg") .attr( "width", width ) .attr( "height", height ); const simulation = d3.forceSimulation( nodes ) .force( "link", d3.forceLink( links ).id( d => d.id ).distance( 100 ) ) .force( "charge", d3.forceManyBody().strength( -300 ) ) .force( "center", d3.forceCenter( width / 2, height / 2 ) ); // Run simulation with simple animation 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("transform", d => `translate(${d.x},${d.y})`); }); // Draw links const link = svg.selectAll( ".link" ) .data(links) .enter().append("line") .attr( "stroke", "#999" ) .attr( "stroke-width", 2 ) .attr( "x1", d => d.source.x ) .attr( "y1", d => d.source.y ) .attr( "x2", d => d.target.x ) .attr( "y2", d => d.target.y ) .attr( "marker-end", "url(#arrowhead)" ); // Draw nodes const node = svg.selectAll( ".node" ) .data( nodes ) .enter().append( "g" ) .attr( "class", "node" ) .attr( "transform", d => `translate(${d.x},${d.y})` ) .call(d3.drag() // Make nodes draggable .on( "start", dragStarted ) .on( "drag", dragged ) .on( "end", dragEnded ) ); // Add hyperlink node.append("a") .attr( "xlink:href", d => d.url ) // Link to the node's URL .attr( "target", "_blank" ) // Open in a new tab .each(function (d) { const a = d3.select(this); // Different shapes for posts and tags if ( d.group === "post" ) { a.append("circle") .attr("r", 10) .attr("fill", "blue"); } else if ( d.group === "tag" ) { // White background rectangle a.append("rect") .attr("width", 20) .attr("height", 20) .attr("x", -10) .attr("y", -10) .attr("fill", "white"); // Red octothorpe a.append("path") .attr("d", "M-10,-5 H10 M-10,5 H10 M-5,-10 V10 M5,-10 V10") .attr("stroke", "red") .attr("stroke-width", 2) .attr("fill", "none"); } // Text label a.append( "text") .attr( "dy", 4 ) .attr( "x", d => ( d.group === "post" ? 12 : 14 ) ) .attr( "fill", "black" ) .style("font-size", "12px" ) .text( d.label ); }); // Standard helper functions to make nodes draggable function dragStarted( event, d ) { if ( !event.active ) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; } function dragged( event, d ) { d.fx = event.x; d.fy = event.y; } function dragEnded( event, d ) { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; }</code></pre><pre><code> </script> </body></html></code></pre><p><strong>Next Steps</strong></p><p>It needs a bit of cleaning up if I want to turn it into a WordPress plugin. It might be nice to make it a static SVG rather than relying on JavaScript. And the general æsthetic needs a bit of work.</p><p>Perhaps I could make it 3D like my <a href="https://shkspr.mobi/blog/2023/04/msc-dissertation-exploring-the-visualisation-of-hierarchical-cybersecurity-data-within-the-metaverse/" rel="nofollow noopener" target="_blank">MSc Dissertation</a>?</p><p>But I'm pretty happy with that for an afternoon hack!</p><p>You can <a href="https://gitlab.com/edent/blog-theme/-/blob/master/includes/graph.php" rel="nofollow noopener" target="_blank">get the code</a> if you want to play.</p><p><a rel="nofollow noopener" class="hashtag u-tag u-category" href="https://shkspr.mobi/blog/tag/blogging/" target="_blank">#blogging</a> <a rel="nofollow noopener" class="hashtag u-tag u-category" href="https://shkspr.mobi/blog/tag/graphs/" target="_blank">#graphs</a> <a rel="nofollow noopener" class="hashtag u-tag u-category" href="https://shkspr.mobi/blog/tag/html/" target="_blank">#HTML</a> <a rel="nofollow noopener" class="hashtag u-tag u-category" href="https://shkspr.mobi/blog/tag/php/" target="_blank">#php</a> <a rel="nofollow noopener" class="hashtag u-tag u-category" href="https://shkspr.mobi/blog/tag/wordpress/" target="_blank">#WordPress</a></p>