diff options
Diffstat (limited to 'src/index.js')
| -rw-r--r-- | src/index.js | 336 |
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)); }; |
