aboutsummaryrefslogtreecommitdiffstats
path: root/src/index.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/index.js')
-rw-r--r--src/index.js336
1 files changed, 195 insertions, 141 deletions
diff --git a/src/index.js b/src/index.js
index fb48d15..460b288 100644
--- a/src/index.js
+++ b/src/index.js
@@ -4,177 +4,231 @@ import * as jsonld from 'jsonld';
import * as dom from './dom';
import Layout from './layout';
import { Note } from './ui';
-
-window.jsonld = jsonld;
+import { render, h, Fragment, Component } from 'preact';
+import { useRef, useLayoutEffect } from 'preact/hooks';
dom.css`
-.graph {
- position: relative;
+article > div {
+ position: relative;
}
-#nodes {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
+article > div > canvas {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
}
`;
-document.body.appendChild(
- <article>
- <h1 id="title">loading...</h1>
- <div class="graph">
- <canvas id="links" />
- <div id="nodes" />
- </div>
- </article>
-);
-
-const title = document.getElementById('title');
-const canvas = document.getElementById('links');
-const nodes = document.getElementById('nodes');
-
const context = [
- 'https://www.w3.org/ns/activitystreams',
- {
- replies: { '@id': 'as:replies', '@container': '@set' },
- inReplyTo: { '@id': 'as:inReplyTo', '@container': '@set' },
- },
+ 'https://www.w3.org/ns/activitystreams',
+ {
+ replies: { '@id': 'as:replies', '@container': '@set' },
+ inReplyTo: { '@id': 'as:inReplyTo', '@container': '@set' },
+ },
];
-const userCache = {};
-const loadUser = async (id) => {
- if (!userCache[id]) {
- userCache[id] = jsonld.compact(id, context).then((user) => {
- user.id = id;
- return user;
- });
- }
-
- return await userCache[id];
-}
-
-const loadData = async () => {
- const raw = await import('../lib/graph.json');
- const data = await jsonld.frame(
- raw.default,
- {
- '@context': context,
- type: 'Document',
- first: { '@embed': '@never' },
- items: {
- context: { '@embed': '@never' },
- replies: { '@embed': '@never' },
- inReplyTo: { '@embed': '@never' },
- },
- },
- { omitGraph: true }
- );
-
- await Promise.all(data.items.map(async (item) => {
- item.attributedTo = await loadUser(item.attributedTo);
- }));
-
- return data;
-};
-
-const createNotes = (graph, discussion) => {
- for (const data of Object.values(discussion.items)) {
- if (data.id in graph)
- continue;
-
- const onlyParent = data.inReplyTo.length === 1 && discussion.items[data.inReplyTo[0].id];
-
- const dom = (
- <Note
- {...data}
- smallHeader={
- onlyParent // only one parent post
- && onlyParent.attributedTo === data.attributedTo // posted by same user
- && onlyParent.published === data.published // in the same second
- }
- onHide={hideNote}
- />
- );
- nodes.appendChild(dom);
-
- graph[data.id] = { dom, data };
- }
-};
+const LinksCanvas = ({ links, ...props }) => {
+ const canvas = useRef(null);
+
+ useLayoutEffect(() => {
+ const ctx = canvas.current.getContext('2d');
+ ctx.lineWidth = 4;
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+ ctx.strokeStyle = '#696969';
+ ctx.fillStyle = '#696969';
+
+ ctx.clearRect(0, 0, canvas.current.width, canvas.current.height);
+
+ for (const points of links) {
+ ctx.beginPath();
+ ctx.moveTo(...points[1]);
+ for (let i = 4; i < points.length; i += 3) {
+ const [ax, ay] = points[i - 2];
+ const [bx, by] = points[i - 1];
+ const [x, y] = points[i];
+ ctx.bezierCurveTo(ax, ay, bx, by, x, y);
+ }
+ ctx.stroke();
+
+ const [lx, ly] = points[points.length - 1];
+ const [tx, ty] = points[0];
+ const [dx, dy] = [tx - lx, ty - ly];
+ const [hx, hy] = [dy*0.7, dx*-0.7];
+
+ ctx.beginPath();
+ ctx.moveTo(lx + dx*0.8, ly + dy*0.8);
+ ctx.lineTo(lx - dx*0.5 + hx, ly - dy*0.5 + hy);
+ ctx.lineTo(lx - dx*0.5 - hx, ly - dy*0.5 - hy);
+ ctx.fill();
+ }
+ });
-const layoutNodes = async (graph) => {
- const layout = new Layout();
-
- for (const node of Object.values(graph))
- layout.addNode(graph, node);
-
- await layout.render(canvas,
- (id, x, y) => {
- const dom = graph[id].dom;
- dom.style.left = `${x - dom.offsetWidth/2}px`;
- dom.style.top = `${y - dom.offsetHeight/2}px`;
- },
- (width, height) => {
- canvas.width = width;
- canvas.height = height;
- },
- );
+ return (
+ <canvas {...props} ref={canvas} />
+ );
};
-const update = async (graph) => {
- const discussion = await loadData();
- window.dagData = discussion;
- title.textContent = discussion.name;
+class App extends Component {
+ state = {
+ discussion: null,
+ positions: {},
+ links: [],
+ width: 0,
+ height: 0,
+ };
+
+ usersCache = {};
+ nodes = {};
+
+ constructor(props) {
+ super(props);
+
+ void this.loadData(this.props.graph);
+ }
+
+ async loadData(url) {
+ // temporary hack
+ if (url.startsWith('http'))
+ url = `https://cors-anywhere.herokuapp.com/${url}`;
+
+ const response = await fetch(url, {
+ headers: {
+ 'Accept': 'application/json',
+ },
+ });
+ const discussion = await jsonld.frame(
+ await response.json(),
+ {
+ '@context': context,
+ type: 'Document',
+ first: { '@embed': '@never' },
+ items: {
+ context: { '@embed': '@never' },
+ replies: { '@embed': '@never' },
+ inReplyTo: { '@embed': '@never' },
+ },
+ },
+ { omitGraph: true }
+ );
+
+ const indexedItems = {};
+ await Promise.all(discussion.items.map(async (item) => {
+ item.attributedTo = await this.loadUser(item.attributedTo);
+ indexedItems[item.id] = item;
+ }));
+
+ discussion.items = indexedItems;
+
+ this.setState({
+ discussion,
+ });
+ }
+
+ async loadUser(id) {
+ if (!this.usersCache[id]) {
+ this.usersCache[id] = jsonld.compact(id, context).then((user) => {
+ user.id = id;
+ return user;
+ });
+ }
- createNotes(graph, discussion);
- await layoutNodes(graph);
+ return await this.usersCache[id];
+ }
+
+ render() {
+ const { name, items } = this.state.discussion ?? { name: 'loading...', items: {} };
+ const { width, height, positions, links } = this.state;
+
+ const layout = new Layout();
+ const setNodeSize = layout.addNode.bind(layout);
+
+ useLayoutEffect(() => {
+ void (async () => {
+ this.setState(await layout.render());
+ })();
+ }, [ items ]);
+
+ return (
+ <article>
+ <h1>{name}</h1>
+ <div>
+ <LinksCanvas width={width} height={height} links={links} />
+ <div>
+ {Object.values(items).map((item) => {
+ const onlyParent = item.inReplyTo.length === 1 && items[item.inReplyTo[0].id];
+
+ for (const reply of item.replies) {
+ const to = items[reply.id];
+ if (item.collapsed && to.collapsed)
+ continue;
+
+ layout.addLink(item.id, to.id);
+ }
+
+ return (
+ <Note
+ {...item}
+ smallHeader={
+ onlyParent // only one parent post
+ && onlyParent.attributedTo === item.attributedTo // posted by same user
+ && onlyParent.published === item.published // in the same second
+ }
+ position={positions[item.id]}
+ onHide={hideNote}
+ onSize={setNodeSize}
+ />
+ );
+ })}
+ </div>
+ </div>
+ </article>
+ );
+ }
+}
- const root = graph[discussion.first];
- root.dom.scrollIntoView({ block: 'nearest', inline: 'nearest' });
-};
+const graph = document.location.hash ? document.location.hash.substr(1) : 'lib/graph.json';
+const app = <App graph={graph} />;
+render(app, document.body);
+window.app = app;
// update collapsed nodes starting with id
// returns list of (potentially) affected nodes
const updateCollapsed = (graph, node, updated=[]) => {
- updated.push(node.data.id);
+ updated.push(node.data.id);
- if (node.collapsed !== 'explicit') {
- node.collapsed =
- node.data.inReplyTo.length
- && node.data.inReplyTo.every(p => graph[p.id].collapsed);
- }
+ if (node.collapsed !== 'explicit') {
+ node.collapsed =
+ node.data.inReplyTo.length
+ && node.data.inReplyTo.every(p => graph[p.id].collapsed);
+ }
- for (const child of node.data.replies)
- updateCollapsed(graph, graph[child.id], updated);
+ for (const child of node.data.replies)
+ updateCollapsed(graph, graph[child.id], updated);
- return updated;
+ return updated;
};
-const graph = {};
-window.graph = graph;
-
-update(graph)
- .catch(err => console.error(err));
-
const hideNote = (e) => {
- const clicked = graph[e.currentTarget.parentElement.dataset.id];
- clicked.collapsed = clicked.collapsed ? false : 'explicit';
+ const clicked = graph[e.currentTarget.parentElement.dataset.id];
+ clicked.collapsed = clicked.collapsed ? false : 'explicit';
- for (const id of updateCollapsed(graph, clicked)) {
- const node = graph[id];
+ for (const id of updateCollapsed(graph, clicked)) {
+ const node = graph[id];
if (node.collapsed) {
- const noParents = node.data.inReplyTo.every(p => graph[p.id].collapsed);
- const noChildren = node.data.replies.every(c => graph[c.id].collapsed);
- node.dom.className = noParents && noChildren ? 'hidden' : 'collapsed';
+ const noParents = node.data.inReplyTo.every(p => graph[p.id].collapsed);
+ const noChildren = node.data.replies.every(c => graph[c.id].collapsed);
+ node.dom.className = noParents && noChildren ? 'hidden' : 'collapsed';
} else {
- node.dom.className = '';
+ node.dom.className = '';
}
- }
+ }
- layoutNodes(graph)
- .then(() => clicked.dom.scrollIntoView({ block: 'nearest', inline: 'nearest' }))
- .catch(err => console.error(err));
+ layoutNodes(graph)
+ .then(() => clicked.dom.scrollIntoView({ block: 'nearest', inline: 'nearest' }))
+ .catch(err => console.error(err));
};