bidirectional links

This commit is contained in:
Garry Ing 2021-03-10 22:33:55 -05:00
parent 22795467af
commit 1b95e9a8df
No known key found for this signature in database
GPG Key ID: 3B379B1F2193CC3E
19 changed files with 643 additions and 7 deletions

View File

@ -15,6 +15,7 @@ gem "jekyll", "~> 4.2.0"
# If you have any plugins, put them here!
group :jekyll_plugins do
gem "jekyll-feed", "~> 0.12"
gem "jekyll-last-modified-at", "~> 1.3"
end
# Windows and JRuby does not include zoneinfo files, so bundle the tzinfo-data gem

View File

@ -11,7 +11,7 @@ GEM
ethon (0.12.0)
ffi (>= 1.3.0)
eventmachine (1.2.7)
ffi (1.14.2)
ffi (1.15.0)
forwardable-extended (2.6.0)
html-proofer (3.18.8)
addressable (~> 2.3)
@ -41,6 +41,9 @@ GEM
terminal-table (~> 2.0)
jekyll-feed (0.15.1)
jekyll (>= 3.7, < 5.0)
jekyll-last-modified-at (1.3.0)
jekyll (>= 3.7, < 5.0)
posix-spawn (~> 0.3.9)
jekyll-sass-converter (2.1.0)
sassc (> 2.0.1, < 3.0)
jekyll-watch (2.2.1)
@ -56,13 +59,12 @@ GEM
mercenary (0.4.0)
nokogiri (1.11.1-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.11.1-x86_64-linux)
racc (~> 1.4)
nokogumbo (2.0.4)
nokogiri (~> 1.8, >= 1.8.4)
parallel (1.20.1)
pathutil (0.16.2)
forwardable-extended (~> 2.6)
posix-spawn (0.3.15)
public_suffix (4.0.6)
racc (1.5.2)
rainbow (3.0.0)
@ -84,12 +86,12 @@ GEM
PLATFORMS
x86_64-darwin-19
x86_64-linux
DEPENDENCIES
html-proofer (~> 3.18)
jekyll (~> 4.2.0)
jekyll-feed (~> 0.12)
jekyll-last-modified-at (~> 1.3)
rake (~> 13.0)
tzinfo (~> 1.2)
tzinfo-data

View File

@ -31,9 +31,31 @@ excerpt_separator: "<!--more-->"
# Build settings
plugins:
- jekyll-feed
- jekyll-last-modified-at
sass:
style: compressed
collections:
notes:
output: true
permalink: /:slug
defaults:
- scope:
path: "**/*"
values:
layout: "default"
- scope:
path: "_notes/**/*.md"
values:
layout: "note"
permalink: pretty
relative_permalinks: false
use_html_extension: false
# Exclude from processing.
# The following items will not be processed, by default.
# Any item listed under the `exclude:` key here will be automatically added to
@ -43,6 +65,7 @@ sass:
# their entries' file path in the `include:` list.
#
exclude:
- _includes/notes_graph.json
- .sass-cache/
- .jekyll-cache/
- gemfiles/

View File

@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% if page.title %}{{ page.title }} 🌱 {% endif %}{{ site.title }}</title>
<meta name="description" content="{{ meta_description }}">
<meta name="description" content="{{ meta_description | escape }}">
<link rel="stylesheet" href="{{ "/assets/css/style.css" | relative_url }}">
{%- feed_meta -%}
</head>

View File

@ -0,0 +1,86 @@
<!-- That file is not particularly elegant. This will need a refactor at some point. -->
<div style="opacity: 0; display: none;" id='tooltip-wrapper'>
<div id='tooltip-content'>
</div>
</div>
<iframe style="display: none; height: 0; width: 0;" id='link-preview-iframe' src="">
</iframe>
<script>
var opacityTimeout;
var contentTimeout;
var transitionDurationMs = 100;
var iframe = document.getElementById('link-preview-iframe')
var tooltipWrapper = document.getElementById('tooltip-wrapper')
var tooltipContent = document.getElementById('tooltip-content')
function hideTooltip() {
opacityTimeout = setTimeout(function() {
tooltipWrapper.style.opacity = 0;
contentTimeout = setTimeout(function() {
tooltipContent.innerHTML = '';
tooltipWrapper.style.display = 'none';
}, transitionDurationMs + 1);
}, transitionDurationMs)
}
function showTooltip(event) {
var elem = event.target;
var elem_props = elem.getClientRects()[elem.getClientRects().length - 1];
var top = window.pageYOffset || document.documentElement.scrollTop
if (event.target.host === window.location.host) {
iframe.src = event.target.href
iframe.onload = function() {
tooltipContentHtml = ''
tooltipContentHtml += '<div style="font-weight: bold;">' + iframe.contentWindow.document.querySelector('h1').innerHTML + '</div>'
tooltipContentHtml += iframe.contentWindow.document.querySelector('content').innerHTML
tooltipContent.innerHTML = tooltipContentHtml
tooltipWrapper.style.display = 'block';
setTimeout(function() {
tooltipWrapper.style.opacity = 1;
}, 1)
}
tooltipWrapper.style.left = elem_props.left - (tooltipWrapper.offsetWidth / 2) + (elem_props.width / 2) + "px";
if ((window.innerHeight - elem_props.top) < (tooltipWrapper.offsetHeight)) {
tooltipWrapper.style.top = elem_props.top + top - tooltipWrapper.offsetHeight - 10 + "px";
} else if ((window.innerHeight - elem_props.top) > (tooltipWrapper.offsetHeight)) {
tooltipWrapper.style.top = elem_props.top + top + 35 + "px";
}
if ((elem_props.left + (elem_props.width / 2)) < (tooltipWrapper.offsetWidth / 2)) {
tooltipWrapper.style.left = "10px";
} else if ((document.body.clientWidth - elem_props.left - (elem_props.width / 2)) < (tooltipWrapper.offsetWidth / 2)) {
tooltipWrapper.style.left = document.body.clientWidth - tooltipWrapper.offsetWidth - 20 + "px";
}
}
}
function setupListeners(linkElement) {
linkElement.addEventListener('mouseleave', function(_event) {
hideTooltip();
});
tooltipWrapper.addEventListener('mouseleave', function(_event) {
hideTooltip();
});
linkElement.addEventListener('mouseenter', function(event) {
clearTimeout(opacityTimeout);
clearTimeout(contentTimeout);
showTooltip(event);
});
tooltipWrapper.addEventListener('mouseenter', function(event) {
clearTimeout(opacityTimeout);
clearTimeout(contentTimeout);
});
}
document.querySelectorAll('{{ include.wrapperQuerySelector }} a').forEach(setupListeners);
</script>

265
_includes/notes_graph.html Normal file
View File

@ -0,0 +1,265 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.16.0/d3.min.js"
integrity="sha512-FHsFVKQ/T1KWJDGSbrUhTJyS1ph3eRrxI228ND0EGaEp6v4a/vGwPWd3Dtd/+9cI7ccofZvl/wulICEurHN1pg=="
crossorigin="anonymous"></script>
<div id="zoom"></div>
<div id="graph-wrapper">
<script>
const MINIMAL_NODE_SIZE = 8;
const MAX_NODE_SIZE = 12;
const ACTIVE_RADIUS_FACTOR = 1.5;
const STROKE = 1;
const FONT_SIZE = 16;
const TICKS = 200;
const FONT_BASELINE = 40;
const MAX_LABEL_LENGTH = 50;
const graphData = {% include notes_graph.json %}
let nodesData = graphData.nodes;
let linksData = graphData.edges;
const nodeSize = {};
const updateNodeSize = () => {
nodesData.forEach((el) => {
let weight =
3 *
Math.sqrt(
linksData.filter((l) => l.source === el.id || l.target === el.id)
.length + 1
);
if (weight < MINIMAL_NODE_SIZE) {
weight = MINIMAL_NODE_SIZE;
} else if (weight > MAX_NODE_SIZE) {
weight = MAX_NODE_SIZE;
}
nodeSize[el.id] = weight;
});
};
const onClick = (d) => {
window.location = d.path
};
const onMouseover = function (d) {
const relatedNodesSet = new Set();
linksData
.filter((n) => n.target.id == d.id || n.source.id == d.id)
.forEach((n) => {
relatedNodesSet.add(n.target.id);
relatedNodesSet.add(n.source.id);
});
node.attr("class", (node_d) => {
if (node_d.id !== d.id && !relatedNodesSet.has(node_d.id)) {
return "inactive";
}
return "";
});
link.attr("class", (link_d) => {
if (link_d.source.id !== d.id && link_d.target.id !== d.id) {
return "inactive";
}
return "";
});
link.attr("stroke-width", (link_d) => {
if (link_d.source.id === d.id || link_d.target.id === d.id) {
return STROKE * 4;
}
return STROKE;
});
text.attr("class", (text_d) => {
if (text_d.id !== d.id && !relatedNodesSet.has(text_d.id)) {
return "inactive";
}
return "";
});
};
const onMouseout = function (d) {
node.attr("class", "");
link.attr("class", "");
text.attr("class", "");
link.attr("stroke-width", STROKE);
};
const sameNodes = (previous, next) => {
if (next.length !== previous.length) {
return false;
}
const map = new Map();
for (const node of previous) {
map.set(node.id, node.label);
}
for (const node of next) {
const found = map.get(node.id);
if (!found || found !== node.title) {
return false;
}
}
return true;
};
const sameEdges = (previous, next) => {
if (next.length !== previous.length) {
return false;
}
const set = new Set();
for (const edge of previous) {
set.add(`${edge.source.id}-${edge.target.id}`);
}
for (const edge of next) {
if (!set.has(`${edge.source}-${edge.target}`)) {
return false;
}
}
return true;
};
const graphWrapper = document.getElementById('graph-wrapper')
const element = document.createElementNS("http://www.w3.org/2000/svg", "svg");
element.setAttribute("width", graphWrapper.getBoundingClientRect().width);
element.setAttribute("height", window.innerHeight * 0.8);
graphWrapper.appendChild(element);
const reportWindowSize = () => {
element.setAttribute("width", window.innerWidth);
element.setAttribute("height", window.innerHeight);
};
window.onresize = reportWindowSize;
const svg = d3.select("svg");
const width = Number(svg.attr("width"));
const height = Number(svg.attr("height"));
let zoomLevel = 1;
const simulation = d3
.forceSimulation(nodesData)
.force("forceX", d3.forceX().x(width / 2))
.force("forceY", d3.forceY().y(height / 2))
.force("charge", d3.forceManyBody())
.force(
"link",
d3
.forceLink(linksData)
.id((d) => d.id)
.distance(70)
)
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collision", d3.forceCollide().radius(80))
.stop();
const g = svg.append("g");
let link = g.append("g").attr("class", "links").selectAll(".link");
let node = g.append("g").attr("class", "nodes").selectAll(".node");
let text = g.append("g").attr("class", "text").selectAll(".text");
const resize = () => {
if (d3.event) {
const scale = d3.event.transform;
zoomLevel = scale.k;
g.attr("transform", scale);
}
const zoomOrKeep = (value) => (zoomLevel >= 1 ? value / zoomLevel : value);
const font = Math.max(Math.round(zoomOrKeep(FONT_SIZE)), 1);
text.attr("font-size", (d) => font);
text.attr("y", (d) => d.y - zoomOrKeep(FONT_BASELINE) + 8);
link.attr("stroke-width", zoomOrKeep(STROKE));
node.attr("r", (d) => {
return zoomOrKeep(nodeSize[d.id]);
});
svg
.selectAll("circle")
.filter((_d, i, nodes) => d3.select(nodes[i]).attr("active"))
.attr("r", (d) => zoomOrKeep(ACTIVE_RADIUS_FACTOR * nodeSize[d.id]));
document.getElementById("zoom").innerHTML = zoomLevel.toFixed(2);
};
const ticked = () => {
node.attr("cx", (d) => d.x).attr("cy", (d) => d.y);
text
.attr("x", (d) => d.x)
.attr("y", (d) => d.y - (FONT_BASELINE - nodeSize[d.id]) / zoomLevel);
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);
};
const restart = () => {
updateNodeSize();
node = node.data(nodesData, (d) => d.id);
node.exit().remove();
node = node
.enter()
.append("circle")
.attr("r", (d) => {
return nodeSize[d.id];
})
.on("click", onClick)
.on("mouseover", onMouseover)
.on("mouseout", onMouseout)
.merge(node);
link = link.data(linksData, (d) => `${d.source.id}-${d.target.id}`);
link.exit().remove();
link = link.enter().append("line").attr("stroke-width", STROKE).merge(link);
text = text.data(nodesData, (d) => d.label);
text.exit().remove();
text = text
.enter()
.append("text")
.text((d) => shorten(d.label.replace(/_*/g, ""), MAX_LABEL_LENGTH))
.attr("font-size", `${FONT_SIZE}px`)
.attr("text-anchor", "middle")
.attr("alignment-baseline", "central")
.on("click", onClick)
.on("mouseover", onMouseover)
.on("mouseout", onMouseout)
.merge(text);
node.attr("active", (d) => isCurrentPath(d.path) ? true : null);
text.attr("active", (d) => isCurrentPath(d.path) ? true : null);
simulation.nodes(nodesData);
simulation.force("link").links(linksData);
simulation.alpha(1).restart();
simulation.stop();
for (let i = 0; i < TICKS; i++) {
simulation.tick();
}
ticked();
};
const zoomHandler = d3.zoom().scaleExtent([0.2, 3]).on("zoom", resize);
zoomHandler(svg);
restart();
function isCurrentPath(notePath) {
return window.location.pathname.includes(notePath)
}
function shorten(str, maxLen, separator = ' ') {
if (str.length <= maxLen) return str;
return str.substr(0, str.lastIndexOf(separator, maxLen)) + '...';
}
</script>
</div>

View File

@ -0,0 +1 @@
{"edges":[{"source":"229320080329284392969231993","target":"50678562812897"},{"source":"229320080329284392969231993","target":"72697617314704"},{"source":"229320080329284392969231993","target":"36028"}],"nodes":[{"id":"50678562812897","path":"/hypertext","label":"Hypertext"},{"id":"72697617314704","path":"/protocols","label":"Protocols"},{"id":"36028","path":"/rss","label":"RSS"},{"id":"229320080329284392969231993","path":"/statement-of-intent","label":"Statement of Intent"}]}

View File

@ -12,6 +12,7 @@
</main>
{%- include footer.html -%}
{% include link-previews.html wrapperQuerySelector="content" %}
<script src="{{ "/assets/js/scripts.js" | relative_url }}" async></script>
</body>
</html>

48
_layouts/note.html Normal file
View File

@ -0,0 +1,48 @@
---
layout: default
---
<div class="flex">
<article class="w-50">
<div>
<h1>{{ page.title }}</h1>
<time datetime="{{ page.last_modified_at | date_to_xmlschema }}">{% if page.type != 'pages' %}
Last updated on {{ page.last_modified_at | date: "%B %-d, %Y" }}
{% endif %}
</time>
</div>
<div id="notes-entry-container">
<content>
{{ content }}
</content>
<side style="font-size: 0.9em">
<h3 style="margin-bottom: 1em">Notes mentioning this note</h3>
{% if page.backlinks.size > 0 %}
<div style="display: grid; grid-gap: 1em; grid-template-columns: repeat(1fr);">
{% for backlink in page.backlinks %}
<div class="backlink-box">
<a class="internal-link" href="{{ backlink.url | relative_url }}{%- if site.use_html_extension -%}.html{%- endif -%}">{{ backlink.title }}</a><br>
<div style="font-size: 0.9em">{{ backlink.excerpt | strip_html | truncatewords: 20 }}</div>
</div>
{% endfor %}
</div>
{% else %}
<div>
<p>
There are no notes linking to this note.
</p>
</div>
{% endif %}
</side>
</div>
</article>
<div class="w-50">
<p>Here are all the notes in this garden, along with their links, visualized as a graph.</p>
{% include notes_graph.html %}
</div>
</div>

View File

@ -31,4 +31,4 @@ layout: default
</div>
<a class="u-url" href="{{ page.url | relative_url }}" hidden></a>
</article>
</article>

5
_notes/hypertext.md Normal file
View File

@ -0,0 +1,5 @@
---
title: Hypertext
---
Hypertext Markup Language

3
_notes/protocols.md Normal file
View File

@ -0,0 +1,3 @@
---
---

5
_notes/rss.md Normal file
View File

@ -0,0 +1,5 @@
---
title: RSS
---
Really Simple Syndication

View File

@ -0,0 +1,5 @@
---
title: Statement of Intent
---
Hyphas practice is situated across many topics that are present in the theme of _Adaptive Reuse & Creative Misuse_. Drawing from our collective experiences, histories, and methodologies, our goal for the micro-residency to investigate how notions of digital infrastructure can be reused, reinterpreted, and reconfigured, to realize a kind of public space. Our approach to this theme will be composed of a few, very preliminary, subjects that will ground the residency: the situated histories of digital infrastructure, the implications of [[protocols]] for publishing ([[Hypertext]], [[RSS]], Peer-to-peer) in defining public spaces, and the possibilities of cooperative approaches to maintenance and repair. Our intent is to make the process of this investigation public through online tools mapping our thinking about the theme (Open channels in Are.na as one example) and cultivating a Digital Public Garden as part of Hyphas contributions to the initiative (a resyndicatable adaptive online notebook). The outputs from the micro-residency will be a written contribution to the [_Field Guide to the Digital Real_](https://www.are.na/from-later/field-guide-to-the-digital-real) and a micro-website containing the synthesis of our investigations and our evolving practice. The outputs will be textual and visual, and draw from our collaborative practices as a cooperative. They will explore ways to represent relationships with existing and emergent technologies within our communities. Through our micro-residency we will capture a poetic interpretation of the theme and provide prompts for institutions in the city on how they could reconfigure technology to create radically creative platforms.

View File

@ -0,0 +1,106 @@
# frozen_string_literal: true
class BidirectionalLinksGenerator < Jekyll::Generator
def generate(site)
graph_nodes = []
graph_edges = []
all_notes = site.collections['notes'].docs
all_pages = site.pages
all_docs = all_notes + all_pages
link_extension = !!site.config["use_html_extension"] ? '.html' : ''
# Convert all Wiki/Roam-style double-bracket link syntax to plain HTML
# anchor tag elements (<a>) with "internal-link" CSS class
all_docs.each do |current_note|
all_docs.each do |note_potentially_linked_to|
title_from_filename = File.basename(
note_potentially_linked_to.basename,
File.extname(note_potentially_linked_to.basename)
).gsub('_', ' ').gsub('-', ' ').capitalize
# Replace double-bracketed links with label using note title
# [[A note about cats|this is a link to the note about cats]]
current_note.content = current_note.content.gsub(
/\[\[#{title_from_filename}\|(.+?)(?=\])\]\]/i,
"<a class='internal-link' href='.#{note_potentially_linked_to.url}#{link_extension}'>\\1</a>"
)
# Replace double-bracketed links with label using note filename
# [[cats|this is a link to the note about cats]]
current_note.content = current_note.content.gsub(
/\[\[#{note_potentially_linked_to.data['title']}\|(.+?)(?=\])\]\]/i,
"<a class='internal-link' href='.#{note_potentially_linked_to.url}#{link_extension}'>\\1</a>"
)
# Replace double-bracketed links using note title
# [[a note about cats]]
current_note.content = current_note.content.gsub(
/\[\[(#{note_potentially_linked_to.data['title']})\]\]/i,
"<a class='internal-link' href='.#{note_potentially_linked_to.url}#{link_extension}'>\\1</a>"
)
# Replace double-bracketed links using note filename
# [[cats]]
current_note.content = current_note.content.gsub(
/\[\[(#{title_from_filename})\]\]/i,
"<a class='internal-link' href='.#{note_potentially_linked_to.url}#{link_extension}'>\\1</a>"
)
end
# At this point, all remaining double-bracket-wrapped words are
# pointing to non-existing pages, so let's turn them into disabled
# links by greying them out and changing the cursor
current_note.content = current_note.content.gsub(
/\[\[(.*)\]\]/i, # match on the remaining double-bracket links
<<~HTML.chomp # replace with this HTML (\\1 is what was inside the brackets)
<span title='There is no note that matches this link.' class='invalid-link'>
<span class='invalid-link-brackets'>[[</span>
\\1
<span class='invalid-link-brackets'>]]</span></span>
HTML
)
end
# Identify note backlinks and add them to each note
all_notes.each do |current_note|
# Nodes: Jekyll
notes_linking_to_current_note = all_notes.filter do |e|
e.content.include?(current_note.url)
end
# Nodes: Graph
graph_nodes << {
id: note_id_from_note(current_note),
path: "#{current_note.url}#{link_extension}",
label: current_note.data['title'],
} unless current_note.path.include?('_notes/index.html')
# Edges: Jekyll
current_note.data['backlinks'] = notes_linking_to_current_note
# Edges: Graph
notes_linking_to_current_note.each do |n|
graph_edges << {
source: note_id_from_note(n),
target: note_id_from_note(current_note),
}
end
end
File.write('_includes/notes_graph.json', JSON.dump({
edges: graph_edges,
nodes: graph_nodes,
}))
end
def note_id_from_note(note)
note.data['title']
.dup
.gsub(/\W+/, ' ')
.delete(' ')
.to_i(36)
.to_s
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
EMPTY_FRONT_MATTER = <<~JEKYLL
---
---
JEKYLL
# Inject empty front matter in notes that don't have any
Jekyll::Hooks.register :site, :after_init do |site|
Dir.glob(site.collections['notes'].relative_directory + '/**/*.md').each do |filename|
raw_note_content = File.read(filename)
unless raw_note_content.start_with?('---')
raw_note_content.prepend(EMPTY_FRONT_MATTER)
File.write(filename, raw_note_content)
end
end
end

View File

@ -5,7 +5,7 @@ title: "Initial Seeds"
Set of areas that guide our reveries?
1. The history of hypertext, rss+adjacent protocols and standards.
1. The history of [hypertext](/hypertext), rss+adjacent protocols and standards.
2. The act of publishing as "making something public" → publicness → hybrid public space.
3. The possibilities for *the infrastructural* (maintenance/repair) to draw from the past to rethink the present through co-operative approaches.

View File

@ -5,4 +5,71 @@
.no-underline-hover:hover,
.no-underline-hover:focus {
text-decoration: none;
}
// Notes Graph
.links line {
stroke: $accent-color;
opacity: 0.5;
}
.nodes circle {
cursor: pointer;
fill: $accent-color;
transition: all 0.15s ease-out;
}
.text text {
cursor: pointer;
fill: $accent-color;
}
.nodes [active],
.text [active] {
cursor: pointer;
fill: $accent-color;
}
.inactive {
opacity: 0.1;
transition: all 0.15s ease-out;
}
#graph-wrapper {
border-radius: 4px;
height: auto;
}
// Link previews
content a.internal-link {
background-color: rgba($accent-color, 0.1);
}
#tooltip-wrapper {
background: white;
padding: 1em;
border: 1px solid $accent-color;
border-radius: 4px;
overflow: hidden;
position: absolute;
width: 400px;
height: 250px;
font-size: 0.8em;
box-shadow: 0 5px 10px rgba(0,0,0,0.1);
opacity: 0;
transition: opacity 100ms;
}
#tooltip-wrapper:after {
content: "";
position: absolute;
z-index: 1;
bottom: 0;
left: 0;
pointer-events: none;
background-image: linear-gradient(to bottom, rgba(255,255,255, 0), rgba(255,255,255, 1) 90%);
width: 100%;
height: 75px;
}

View File