diff options
| author | s-ol <s-ol@users.noreply.github.com> | 2021-02-05 15:25:50 +0000 |
|---|---|---|
| committer | s-ol <s-ol@users.noreply.github.com> | 2021-02-05 15:25:50 +0000 |
| commit | e5e73c78f04015c99536bce368a2330f297f4afe (patch) | |
| tree | 05bd064a700ebe72dc428ea57515f38105f9a05c | |
| parent | lazy-load graph.json (diff) | |
| download | fedidag-e5e73c78f04015c99536bce368a2330f297f4afe.tar.gz fedidag-e5e73c78f04015c99536bce368a2330f297f4afe.zip | |
react-ify
| -rw-r--r-- | package.json | 5 | ||||
| -rw-r--r-- | src/dom.js | 42 | ||||
| -rw-r--r-- | src/index.js | 336 | ||||
| -rw-r--r-- | src/layout.js | 64 | ||||
| -rw-r--r-- | src/ui.js | 38 | ||||
| -rw-r--r-- | yarn.lock | 5 |
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": { @@ -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, + }; } } @@ -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); -}; @@ -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" |
