11 KiB
Arcology Sitemap
- Generating the Graph
ArcologyWeb.Sigma
ArcologyWeb.SitemapController
renders a graphic web of pages- JavaScript for rendering the Sitemap
- Page Template
ArcologyWeb.SitemapView
Navigating the Arcology is supposed to be a somewhat curated experience. I either send out a direct link to a page, and people can read and explore from there, or they go to one of the index pages like The Arcology Garden, The Complete Computing Environment, or The Lion's Rear. Those index pages are carefully curated, if not to send people on a romp through the respective garden, then to at least not get lost if they go back to that page.
That said, I've always had a visual streak, I like being able to see the forest for the trees, as it were. Being able to see the full set of relationships is a useful thing; org-roam provides a server process which can provide a relationship graph for a page or my entire Knowledge Base, but I would like to have something similar directly in the Arcology. That is what this page describes, a sitemap which rather than listing the pages in a heirarchy lists them in a connected graph.
For now, you have to navigate it somewhat blindly, the locations are not entirely deterministic as a graph settling algorithm is run after the pages are spit out on the canvas deterministically based on a hash of their title and id.
Generating the Graph ArcologyWeb.Sigma
The ArcologyWeb.Sigma
module generates a Map
of the site, a sitemap. The shape of this map is specifically tuned to the input format of Sigma JS, the map should be able to be fed to the JS frontend with a pass through a JSON serialize/deserialize.
network/1
and network/0
create lists of ArcologyWeb.Sigma.Node
and ArcologyWeb.Sigma.Edge
structures and then converts them to a big map.
defmodule ArcologyWeb.Sigma do
@moduledoc "Generates a map in the shape of Sigma JS network"
alias ArcologyWeb.Sigma.Node
alias ArcologyWeb.Sigma.Edge
use Memoize
def network(), do: network(Arcology.Roam.Link.files())
defmemo network(links) do
nodes =
links
|> Node.from_links()
|> Enum.map(&Node.add_hashed_locations(&1))
|> Enum.map(&Node.add_site_colors(&1))
|> Enum.map(&Node.to_map(&1))
edges =
links
|> Edge.from_links()
|> Enum.map(&Edge.to_map(&1))
%{nodes: nodes, edges: edges}
end
end
Node
Sigma nodes exist to make it easy to translate a link in to two nodes (which are closest really to Arcology.Roam.Files, than anything they are here). from_links/1
takes a list and from_link/3
takes an Arcology.Roam.Link, and are described below. This code is, frankly, brutish, and I'll have to rewrite all of this once I better understand how elixir structs work. these Map.get
calls everywhere are not great, I don't understand why structs
don't implement Access
…
defmodule ArcologyWeb.Sigma.Node do
alias ArcologyWeb.Sigma.Node
defstruct [:id, :title, :url, :x, :y, :color]
import Ecto.Query
def to_map(node) do
%{
id: Map.get(node, :id),
title: Map.get(node, :title),
label: Map.get(node, :title),
url: Map.get(node, :url),
x: Map.get(node, :x),
y: Map.get(node, :y),
color: Map.get(node, :color),
}
end
<<node-from_links>>
<<node-from_link>>
<<node-modifiers>>
end
from_links/1
loads a list of all the Arcology.Roam.Title and Arcology.Roam.Keyword objects. These are sent with the link to from_file/3
.
def from_links(links) do
# load keywords for all these files
keywords = Arcology.Roam.Keyword.all("ARCOLOGY_KEY")
titles = Arcology.Roam.Title.all()
Enum.map(links, &from_link(&1, keywords, titles))
|> List.flatten()
|> Enum.filter(&!is_nil(&1))
|> MapSet.new()
|> MapSet.to_list()
end
defmodule ArcologyWeb.SigmaNodeTests do
use ExUnit.Case
import Ecto.Query
test 'every file is in the node graph' do
node_list =
Arcology.Roam.Links.files()
|> ArcologyWeb.Sigma.Node.from_links()
file_count = from l: Arcology.Roam.File, select: count(l)
assert length(node_list) == file_count
end
end
from_link/3
assembles Node
objects. This url_for_arcology_key
needs to be put in the same place as the code for mapping colors, and the Keyword-based routing code in ArcologyWeb.PageController… Eventually.
@doc "return a pair of nodes based on the link. keywords and titles are lists of possible objects"
def from_link(link, keywords, titles) do
for direction <- [:source, :dest] do
key =
Enum.find(keywords, fn kw ->
!is_binary(link) && kw.file == Map.get(link, direction)
end)
|> Arcology.Roam.Keyword.get_value()
title =
Enum.filter(titles, fn title ->
title.file == Map.get(link, direction)
end)
|> Arcology.Roam.Title.to_list()
|> Enum.at(0)
cond do
key == nil ->
nil
title == nil ->
%Node{
id: key,
title: '(Untitled)',
url: Arcology.LinkRouter.Local.url_for_arcology_key(key)
}
true ->
%Node{
id: key,
title: title,
url: Arcology.LinkRouter.Local.url_for_arcology_key(key)
}
end
end
end
add_hashed_locations/1
and add_site_colors/1
take Node
objects and add somewhat-static locations, and then color the node based on the site in the ARCOLOGY_KEY
.
@doc """
add hashed coordinates to node.
x is the sum of the bytes of the md5 of the id mod 400
y is the sum of the bytes of the md5 of the title mod 400
"""
def add_hashed_locations(node) do
accumulator = fn last, acc ->
rem(last + acc, 400)
end
hasherizer = fn text ->
:crypto.hash(:md5, text)
|> :binary.bin_to_list()
|> Enum.reduce(accumulator)
end
%Node{node | x: hasherizer.(Map.get(node, :id)), y: hasherizer.(Map.get(node, :title))}
end
def add_site_colors(node) do
node_id = Map.get(node, :id)
site = Arcology.Page.split_route(node_id) |> Keyword.get(:site)
%Node{node | color: Arcology.KeyMaps.site_key_to_color(site)}
end
Edge
defmodule ArcologyWeb.Sigma.Edge do
alias ArcologyWeb.Sigma.Edge
defstruct [:id, :source, :target, color: "#BAAD9B", type: "curvedArrow"]
import Ecto.Query
def from_links(links) do
# load keywords and titles for all these files only once
keywords = Arcology.Roam.Keyword.all("ARCOLOGY_KEY")
titles = Arcology.Roam.Title.all()
Enum.map(links, &from_link(&1, keywords, titles))
|> List.flatten()
|> Enum.filter(&(&1 != nil))
|> MapSet.new()
|> MapSet.to_list()
end
@doc "return a pair of nodes based on the link. keywords and titles are lists of possible objects"
def from_link(link, keywords, titles) do
[from_key, to_key] =
for direction <- [:source, :dest] do
Enum.find(keywords, fn kw ->
!is_binary(link) && kw.file == Map.get(link, direction)
end)
|> Arcology.Roam.Keyword.get_value()
end
if from_key && to_key do
%Edge{
id: from_key <> to_key,
source: from_key,
target: to_key
}
else
nil
end
end
def to_map(edge) do
%{
id: Map.get(edge, :id),
source: Map.get(edge, :source),
target: Map.get(edge, :target),
color: Map.get(edge, :color),
type: Map.get(edge, :type)
}
end
end
ArcologyWeb.SitemapController
renders a graphic web of pages
There are two actions here, sigma_network
is called in the JavaScript below, embedded in the page template served by the controller. Nothing special. It loads the text from
defmodule ArcologyWeb.SitemapController do
use ArcologyWeb, :controller
def sitemap(conn, _params) do
keywords = Arcology.Roam.Keyword.get("ARCOLOGY_KEY", "arcology/sitemap_template")
page =
keywords
|> Enum.at(0)
|> Map.get(:f)
|> Arcology.Roam.File.preloads()
|> Arcology.Page.from_file()
|> Arcology.Page.with_localized_html()
conn
|> render(:sitemap, %{
site_title: "Arcology",
page_title: "Sitemap",
html: page.html
})
end
def sigma_network(conn, _params) do
conn
|> json(ArcologyWeb.Sigma.network())
end
end
JavaScript for rendering the Sitemap
This JavaScript is mostly cobbled together from examples provided by Sigma JS; on page load, this code will try to fetch the generated graph from the /sigma_network
endpoint, through to ArcologyWeb.Sigma
, and then run a "settling" function for five seconds to make the network legible.
import * as sigma from 'sigma';
import 'sigma/build/plugins/sigma.layout.forceAtlas2.min';
import 'sigma/build/plugins/sigma.plugins.relativeSize.min';
import 'sigma/build/plugins/sigma.renderers.customEdgeShapes.min';
function process(data) {
var s = new sigma('sigma-network');
s.settings('labelThreshold', 3);
s.settings('minArrowSize', 10);
s.graph.read(data);
console.log("Starting Force");
s.startForceAtlas2();
s.bind('clickNode', function(data) {
window.location = data.data.node.url;
});
setTimeout(function() {
s.stopForceAtlas2();
sigma.plugins.relativeSize(s, 100);
s.refresh();
console.log("Finished Force");
}, 4000);
window.net = s;
};
fetch('/sigma_network')
.then(res => res.json())
.then(res => process(res))
.catch(err => console.error(err));
Page Template
Simple, lol. Just a big ol' div and a reference to that JavaScript.
<script src="/js/s1gma.js" type="text/javascript"></script>
<div>
<%= raw assigns[:html] %>
</div>
<div style="height: 55em; max-height: 55em;" id="sigma-network"></div>
ArcologyWeb.SitemapView
defmodule ArcologyWeb.SitemapView do
use ArcologyWeb, :view
end