aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authors-ol <s-ol@users.noreply.github.com>2021-02-05 15:25:50 +0000
committers-ol <s-ol@users.noreply.github.com>2021-02-05 15:25:50 +0000
commite5e73c78f04015c99536bce368a2330f297f4afe (patch)
tree05bd064a700ebe72dc428ea57515f38105f9a05c
parentlazy-load graph.json (diff)
downloadfedidag-e5e73c78f04015c99536bce368a2330f297f4afe.tar.gz
fedidag-e5e73c78f04015c99536bce368a2330f297f4afe.zip
react-ify
-rw-r--r--package.json5
-rw-r--r--src/dom.js42
-rw-r--r--src/index.js336
-rw-r--r--src/layout.js64
-rw-r--r--src/ui.js38
-rw-r--r--yarn.lock5
6 files changed, 245 insertions, 245 deletions
diff --git a/package.json b/package.json
index 6003ff3..d28dd72 100644
--- a/package.json
+++ b/package.json
@@ -14,8 +14,8 @@
[
"@babel/plugin-transform-react-jsx",
{
- "pragma": "dom.createElement",
- "pragmaFrag": "dom.createFragment"
+ "pragma": "h",
+ "pragmaFrag": "Fragment"
}
]
]
@@ -25,6 +25,7 @@
"core-js": "^3.8.3",
"date-fns": "^2.16.1",
"jsonld": "^3.3.0",
+ "preact": "^10.5.12",
"regenerator-runtime": "^0.13.7"
},
"devDependencies": {
diff --git a/src/dom.js b/src/dom.js
index dd15902..45f8a7a 100644
--- a/src/dom.js
+++ b/src/dom.js
@@ -11,45 +11,3 @@ export const css = ([ code] ) => {
return style;
};
-
-const appendChild = (parent, child) => {
- if (child === null || child === undefined || child === false)
- return;
-
- if (Array.isArray(child)) {
- child.forEach((nestedChild) => appendChild(parent, nestedChild));
- } else {
- parent.appendChild(child.nodeType ? child : document.createTextNode(child));
- }
-}
-
-const objectProps = { style: true, dataset: true };
-
-export const createElement = (tag, props, ...children) => {
- if ('function' === typeof tag)
- return tag(props, children);
-
- const element = document.createElement(tag);
-
- for (const [name, value] of Object.entries(props || {})) {
- if (name.startsWith('on') && name.toLowerCase() in window) {
- element.addEventListener(name.toLowerCase().substr(2), value);
- } else if (name === 'css') {
- for (const [prop, val] of Object.entries(value)) {
- element.style.setProperty(prop, val);
- }
- } else if (objectProps[name]) {
- Object.assign(element[name], value);
- } else {
- element.setAttribute(name, value.toString());
- }
- }
-
- for (const child of children) {
- appendChild(element, child);
- }
-
- return element;
-}
-
-export const createFragment = (props, ...children) => children;
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));
};
diff --git a/src/layout.js b/src/layout.js
index bda2886..b774c31 100644
--- a/src/layout.js
+++ b/src/layout.js
@@ -8,69 +8,39 @@ export default class Layout {
this.src = 'digraph { node [shape="rectangle"];\n';
}
- addNode(graph, node) {
- if (node.dom.className === 'hidden')
- return;
-
- const id = node.data.id;
- this.src += ` "${id}" [fixedsize=true; width=${node.dom.offsetWidth / 72}; height=${node.dom.offsetHeight / 72}];\n`;
-
- for (const reply of node.data.replies) {
- if (node.collapsed && graph[reply.id].collapsed)
- continue;
+ addNode(id, width, height) {
+ this.src += ` "${id}" [fixedsize=true; width=${width / 72}; height=${height / 72}];\n`;
+ }
- this.src += ` "${id}" -> "${reply.id}";\n`;
- }
+ addLink(from, to) {
+ this.src += ` "${from}" -> "${to}";\n`;
}
- async render(canvas, setPosition, setSize) {
+ async render() {
this.src += '}';
const info = await gv.renderJSONObject(this.src, { format: 'json0' });
+ const [width, height] = info.bb.split(',').map(toNumber).slice(2);
- const [_x, _y, width, height] = info.bb.split(',').map(toNumber);
-
- setSize(width, height);
-
+ const positions = {};
for (const object of info.objects) {
const [x, y] = object.pos.split(',').map(toNumber);
- setPosition(object.name, x, height - y);
+ positions[object.name] = [ x, height - y ];
}
- const ctx = canvas.getContext('2d');
- ctx.lineWidth = 4;
- ctx.lineCap = 'round';
- ctx.lineJoin = 'round';
- ctx.strokeStyle = '#696969';
- ctx.fillStyle = '#696969';
-
- for (const edge of info.edges) {
+ const links = info.edges.map((edge) => {
const pos = edge.pos.slice(2);
- const points = pos.split(' ').map(p => {
+ return pos.split(' ').map(p => {
const [x, y] = p.split(',');
return [toNumber(x), height - toNumber(y)];
});
- 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();
- }
+ return {
+ width, height,
+ positions,
+ links,
+ };
}
}
diff --git a/src/ui.js b/src/ui.js
index 8e20e4d..ce707b7 100644
--- a/src/ui.js
+++ b/src/ui.js
@@ -1,3 +1,5 @@
+import { render, h, Fragment } from 'preact';
+import { useRef, useLayoutEffect } from 'preact/hooks';
import ColorHash from 'color-hash';
import { formatRelative } from 'date-fns';
import * as dom from './dom';
@@ -53,7 +55,7 @@ section {
border-radius: 0.4em;
}
-section.tombstone {
+section.type-Tombstone {
color: var(--theme-note-bg);
background: var(--theme-note-fg);
}
@@ -127,23 +129,38 @@ section.collapsed > main {
`;
const colors = new ColorHash({ lightness: 0.8 });
-export const Note = ({ id, type, attributedTo, published, content, smallHeader = false, onHide }) => {
+export const Note = ({
+ id, type, attributedTo, published, content,
+ smallHeader = false, position,
+ onHide, onSize,
+}) => {
const backgroundColor = attributedTo.color || colors.hex(attributedTo.id);
const username = attributedTo.name || attributedTo.preferredUsername;
+ const root = useRef(null);
+ useLayoutEffect(() => {
+ onSize(id, root.current.offsetWidth, root.current.offsetHeight);
+ });
+
return (
<section
- css={{ '--theme-header-bg': backgroundColor }}
- class={type === 'Tombstone' ? 'tombstone' : ''}
- dataset={{ id }}
+ style={{
+ '--theme-header-bg': backgroundColor,
+ 'left': position && `${position[0] - root.current.offsetWidth / 2}px`,
+ 'top': position && `${position[1] - root.current.offsetHeight / 2}px`,
+ 'visibility': position ? 'visible' : 'hidden',
+ }}
+ className={`type-${type}`}
+ data-id={id}
+ ref={root}
>
{smallHeader
- ? <header class="small" onClick={onHide} />
+ ? <header className="small" onClick={onHide} />
: <header onClick={onHide}>
<img src={new URL(attributedTo.icon.url, attributedTo.id)} />
- <span class="user">{username}</span>
+ <span className="user">{username}</span>
<Space />
- <span class="time"><Time time={published} /></span>
+ <span className="time"><Time time={published} /></span>
</header>
}
<main>
@@ -152,8 +169,3 @@ export const Note = ({ id, type, attributedTo, published, content, smallHeader =
</section>
);
};
-
-export const updateColor = (note, { attributedTo }) => {
- const backgroundColor = attributedTo.color || colors.hex(attributedTo.id);
- note.style.setProperty('--theme-header-bg', backgroundColor);
-};
diff --git a/yarn.lock b/yarn.lock
index 690c72c..f3d27aa 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3804,6 +3804,11 @@ posix-character-classes@^0.1.0:
resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=
+preact@^10.5.12:
+ version "10.5.12"
+ resolved "https://registry.yarnpkg.com/preact/-/preact-10.5.12.tgz#6a8ee8bf40a695c505df9abebacd924e4dd37704"
+ integrity sha512-r6siDkuD36oszwlCkcqDJCAKBQxGoeEGytw2DGMD5A/GGdu5Tymw+N2OBXwvOLxg6d1FeY8MgMV3cc5aVQo4Cg==
+
pretty-error@^2.1.1:
version "2.1.2"
resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-2.1.2.tgz#be89f82d81b1c86ec8fdfbc385045882727f93b6"