UNPKG

wiki-plugin-solo

Version:
578 lines (526 loc) 19.4 kB
<!DOCTYPE html> <html> <head> <title>Solo Super Collaborator</title> <link id="favicon" href="/favicon.png" rel="icon" type="image/png" /> <link href="./style.css" rel="stylesheet" /> <script src="https://unpkg.com/@hpcc-js/wasm@1.20.1/dist/index.min.js"></script> <script> var hpccWasm = window["@hpcc-js/wasm"]; </script> <script src="https://cdn.jsdelivr.net/gh/dash14/svg-pan-zoom/dist/svg-pan-zoom.min.js"></script> <script type="module"> </script> </head> <body> <main> <div id="beam" ondrop="drop(event)" ondragover="over(event)" ondragenter="over(event)"> <div id="beamlist" onchange="dochoose(event)"></div> </div> <div id="target" ondrop="drop(event)" ondragover="over(event)" ondragenter="over(event)"> </div> <div id="create" ondrop="drop(event)" ondragover="over(event)" ondragenter="over(event)"> <div style="padding:16px; font-size:large;">More about <a href=http://search.dojo.fed.wiki/solo-super-collaborator.html target=_blank>Solo</a> <p> <button onclick=dogroup(event)>group</button> <button onclick=dopreview(event)>preview</button> <button onclick=dobridge(event)>bridge</button> </p> <p><input id=search type=text placeholder="Search"></p> </div> </main> <script type="module"> const beam = [] let pageKey // window.postMessage({type:'batch',graphs:(await want('wards-favorites')).slice(0,15)}) window.want = async file => fetch(`http://found.ward.fed.wiki/assets/pages/aspects-of-recent-changes/${file}.jsonl`) .then(res => res.text()) .then(text => text.trim().split(/\n/) .map(line => JSON.parse(line))) window.addEventListener("message",event => { const data = event.data console.log({data}) pageKey = data.pageKey beam.splice(0) window.beamlist.querySelectorAll('input[type=checkbox]:checked') .forEach(elem => elem.checked = false) window.target.innerHTML = '' for (const source of data.sources){ source.aspects.forEach(aspect => aspect.label = source.source) beam.push(...source.aspects)} console.log({data,beam}) refreshBeam() }) document.getElementById('search') .addEventListener('keydown', event => { if (event.keyCode === 13) { event.preventDefault() event.target.value = '' } else { const term = (event.target.value+event.key).toLowerCase() const want = text => text?.toLowerCase().includes(term) const nodes = beam .map(aspect => aspect.graph.nodes) .flat() .filter(node => want(node.type) || want(node.props.name)) console.log({term,nodes}) recommend(nodes) } }) function refreshBeam() { const want = [...window.beamlist.querySelectorAll('input[type=checkbox]:checked')] .map(e => +e.value) console.log('refreshBeam',want) // if (!want.length) window.dochoose({}) const names = beam.map(poem => poem.name || poem.graph.nodes[0].type) // let then = 1 window.beamlist.innerHTML = names .map((n,i) => { const poem = beam[i] let sep = i && beam[i-1].label == beam[i].label ? '' : beam[i].label // for (const section of sections) { // if (then && poem.date && then<section.date && poem.date>=section.date) { // sep = `<div><font color=gray>Within ${section.period}</font></div>` // } // } // then = poem.date || 1 const hover = poem.date ? `title="${new Date(poem.date).toLocaleString()}"` : '' const number = `<sup>${poem.graph.nodes.length}</sup>` return `${sep}<div><font color=gray size=1>${i}</font> <input type=checkbox value=${i} id=n${i} ${want.includes(i)?'checked':''}> <label for=n${i}>${n}${number}</label></div>` }) .join("\n") const last = window.beamlist.querySelector('div:last-of-type') if(last) last.scrollIntoView({behavior: "smooth", block: "end", inline: "nearest"}) } window.dochoose = function (event) { const checked = [...window.beam.querySelectorAll('input[type=checkbox]:checked')] const chosen = checked.map(e => Object.assign({index:e.value},beam[e.value])) console.log({chosen}) display(chosen) recommend(chosen.map(poem => poem.graph.nodes).flat()) } function recommend(like) { const vocabulary = new Set() const hash = node => `${node.type}: ${node.props.name || ''}` for (const node of like) { vocabulary.add(hash(node)) } const similar = graph => { for (const node of graph.nodes) { if (vocabulary.has(hash(node))) return hash(node).replaceAll(/\n/g,' ') } return null } showwhen(similar) } window.dobridge = event => { const checked = [...window.beam.querySelectorAll('input[type=checkbox]:checked')] const chosen = checked.map(e => Object.assign({index:e.value},beam[e.value])) const complex = composite(chosen) const parts = partitions(complex.graph) bridge(parts) } function bridge(graphs) { // for every unselected aspect // for every selected aspect // if first match remember // if second match recommend unselected const hash = node => `${node.type}: ${node.props.name || ''}` const touch = ((a,b) => { for (const anode of a.nodes) for (const bnode of b.nodes) if(hash(anode) == hash(bnode)) return true return false }) const wanted = unselected => { let any = false for (const selected of graphs) { if (touch(unselected, selected)) { if (any) return true any = true } } } showwhen(wanted) } function showwhen(predicate) { window.beam.querySelectorAll('input[type=checkbox]').forEach(elem => { const color = !elem.checked && predicate(beam[elem.value].graph) ? 'darkorange' : 'black' elem.nextElementSibling.style.color = color // const title = !elem.checked && predicate(beam[elem.value].graph) ? JSON.stringify(similar(beam[elem.value].graph)) : '' // elem.nextElementSibling.setAttribute('title', title) }) } let drawing = false let panSVG = null const panZoom = {} async function display(chosen) { if(!drawing){ drawing = true const complex = composite(chosen) try { if (document.querySelector('#target svg')) { panZoom.pan = panSVG.getPan() panZoom.zoom = panSVG.getZoom() panZoom.size = { width: document.querySelector('#target svg').width.baseVal.valueInSpecifiedUnits, height: document.querySelector('#target svg').height.baseVal.valueInSpecifiedUnits } } const dot = dotify(complex) window.dot = dot hpccWasm.graphviz.layout(window.dot, "svg", "dot").then(svg => { target.innerHTML = svg; drawing = false hoverbold(target) clickready(target,complex.graph) const targetBounds = { width: document.querySelector('#target').clientWidth, height: document.querySelector('#target').clientHeight } const svgBounds = { width: document.querySelector('#target svg').clientWidth, height: document.querySelector('#target svg').clientHeight } let svgElement = document.querySelector('#target svg') panSVG = svgPanZoom(svgElement) document.querySelector('#target svg').style.height = "100%" document.querySelector('#target svg').style.width = "100%" if (targetBounds.width < svgBounds.width || targetBounds.height < svgBounds.height) { panSVG.resize() } panSVG.fit() panSVG.center() if (panZoom.size && panZoom.size.width == document.querySelector('#target svg').width.baseVal.valueInSpecifiedUnits && panZoom.size.height == document.querySelector('#target svg').height.baseVal.valueInSpecifiedUnits) { panSVG.zoom(panZoom.zoom) panSVG.pan(panZoom.pan) } }) } catch (err) { console.log('display error', err) drawing = false } } else { console.log('display: skipping', chosen) } } const uniq = (value, index, self) => self.indexOf(value) === index class Graph { constructor(nodes=[], rels=[]) { this.nodes = nodes; this.rels = rels; } addNode(type, props={}){ const obj = {type, in:[], out:[], props}; this.nodes.push(obj); return this.nodes.length-1; } addRel(type, from, to, props={}) { const obj = {type, from, to, props}; this.rels.push(obj); const rid = this.rels.length-1; this.nodes[from].out.push(rid) this.nodes[to].in.push(rid); return rid; } tally(){ const tally = list => list.reduce((s,e)=>{s[e.type] = s[e.type] ? s[e.type]+1 : 1; return s}, {}); return { nodes:tally(this.nodes), rels:tally(this.rels)}; } size(){ return this.nodes.length + this.rels.length; } static load(obj) { // let obj = await fetch(url).then(res => res.json()) return new Graph(obj.nodes, obj.rels) } static async fetch(url) { const obj = await fetch(url).then(res => res.json()) return Graph.load(obj) } static async read(path) { const json = await Deno.readTextFile(path); const obj = JSON.parse(json); return Graph.load(obj) } stringify(...args) { const obj = { nodes: this.nodes, rels: this.rels } return JSON.stringify(obj, ...args) } } function partitions(graph) { const input = graph const output = [] // graphs let doing = {} // nid => new nid const nodes = input.nodes const rels = input.rels const todo = [...Array(nodes.length).keys()] .map(n => [n,Math.random()]) .sort((a,b)=>a[1]-b[1]) .map(v=>v[0]) const copy = nid => { if(nid in doing) return todo.splice(todo.indexOf(nid),1) const node = nodes[nid] doing[nid] = output[0].addNode(node.type,node.props) for (const rid of node.out) copy(rels[rid].to) for (const rid of node.in) copy(rels[rid].from) for (const rid of node.out) output[0].addRel('',doing[nid],doing[rels[rid].to],{}) } while(todo.length) { const nid = todo.shift() if (nid in doing) continue const node = nodes[nid] if (node.in.length + node.out.length) { output.unshift(new Graph()) doing = {} copy(nid) } } return output } function composite(concepts) { const merged = {nids:[]} const comp = new Graph() for (const concept of concepts) { const {name,graph} = concept merge(comp,graph,name) } return {graph:comp, merged} function merge(comp,incr,source) { function mergeprops(into,from) { const keys = Object.keys(into) .concat(Object.keys(from)) .filter(uniq) for (const key of keys) { if (into[key]) { // if (from[key] && into[key] != from[key]) { // window.result.innerHTML += // `<div style="font-size:small; padding:4px; background-color:#fee; border-radius:4px; border:1px solid #aaa;"> // conflict for "${key}" property<br> // choosing "${into[key]}" over "${from[key]}"</div>` // } } else { if(from[key]) { into[key] = from[key] } } } } const nids = {} // incr => comp incr.nodes.forEach((node,id) => { const match = comp.nodes.find(each => each.type == node.type && each.props.name == node.props.name) if(match) { nids[id] = comp.nodes.findIndex(node => node === match) merged.nids.push(nids[id]) mergeprops(match.props, node.props) } else { nids[id] = comp.addNode(node.type,node.props) } }) incr.rels.forEach(rel => { const match = comp.rels.find(each => each.type == rel.type && each.from == nids[rel.from] && each.to == nids[rel.to] ) if(match) { mergeprops(match.props, rel.props) } else { rel.props.source = source comp.addRel(rel.type, nids[rel.from], nids[rel.to], rel.props) } }) } } // render graph as dot input to graphviz function dotify(complex) { const inputs = ["read", "get", "first"] const safe = string => string.replaceAll ? string.replaceAll(/"/g,'') : string const epoch = number => number.toString().match(/^\d{13}$/) ? new Date(number).toLocaleDateString() : number.toString().match(/\n/) ? number.replaceAll(/\n/g,' ') : number const {graph, merged} = complex const tip = props => Object.entries(props||{}) .filter(e => e[1]) .map(e => `${e[0]}: ${safe(epoch(e[1]))}`) .join("\\n") const layout = graph.nodes .filter(node => node.type == 'Graphviz' && node.props.layout) .map(node => node.props.layout) ?.reverse()[0] const emphasis = graph.nodes .filter(node => node.type == 'Graphviz' && node.props.emphasis) .map(node => node.props.emphasis) .reduce((sum,each) => Object.assign(sum,each), {}) console.log('dotify',emphasis) window.graph = graph const nodes = graph.nodes.map((node,id) => { const icon = node.props.url ? " 🔗" : node.props.tick ? " ☐" : "" const label = `${node.type}\\n${safe(node.props.name)}${icon}` const color = emphasis[node.type] || (node.props.color ? `fillcolor="${node.props.color}"` : '') return `${id} [label="${label}" ${color} ${(node.props.url||node.props.tick)?`URL="${node.props.url||'#'}" target="_blank"`:''} tooltip="${tip(node.props)}"]` }) // if inputs.include?r['type'] // file.puts "#{r['to']} -> #{r['from']} [#{props} dir=back]" // else // file.puts "#{r['from']} -> #{r['to']} [#{props}]" // end const omits = new Set() const edges = graph.rels.map(rel => { const color = emphasis[rel.type] || '' if (graph.rels.find(rev => rev.from==rel.to && rev.to==rel.from)) { if (omits.has(`${rel.to} -> ${rel.from}`)) return '' omits.add(`${rel.from} -> ${rel.to}`) return `${rel.from}->${rel.to} [label="${rel.type}" ${color} labeltooltip="${tip(rel.props)}" dir=both]` } if (inputs.includes(rel.type)) return `${rel.to}->${rel.from} [label="${rel.type}" ${color} labeltooltip="${tip(rel.props)}" dir=back]` else return `${rel.from}->${rel.to} [label="${rel.type}" ${color} labeltooltip="${tip(rel.props)}"]` }) return [ 'strict digraph {', 'overlap = false; splines=true', `layout = ${layout||'dot'}`, 'node [shape=box style=filled fillcolor=gold penwidth=2]', ...merged.nids, 'node [fillcolor=palegreen penwidth=1]', ...nodes, ...edges, '}'].join("\n") } // embolden lines from graphviz svg nodes on hover function hoverbold(div,select='both') { let rels = [] // [[edge, from, to], ...] const title = target => target.querySelector('title').textContent.replace(/\\n/g,' ') div.querySelectorAll('.edge').forEach(edge => rels.push([edge, ...title(edge).split('->')])) const trace = (node,doit) => { const related = io => rels.filter(row => row[io]==node) switch (select) { case 'output': related(1).map(row => doit(row[0])) break case 'input': related(2).map(row => doit(row[0])) break case 'both': related(1).map(row => doit(row[0])) related(2).map(row => doit(row[0])) break case 'most': let o = related(1).length let i = related(2).length related(o>i?1:2).map(row => doit(row[0])) break } } const feature = (event,width) => { if(width==3) console.log([...event.target.querySelectorAll('text')].map(e => e.innerHTML).join(" ")) } const stroke = width => { const adjust = edge => edge.setAttribute('stroke-width',width) return event => {feature(event,width); return trace(title(event.target), adjust)} } div.querySelectorAll('.node').forEach(node => { node.addEventListener('mouseenter',stroke(3)) node.addEventListener('mouseleave',stroke(1)) }) } function clickready(div,graph) { div.querySelectorAll('.node').forEach(node => { node.addEventListener('click', event => { const target = event.target const node = target.closest('.node') const nid = +node.querySelector('title').textContent const props = graph.nodes[nid].props console.log({event,target,node,nid,props}) if (event.altKey) { recommend([graph.nodes[nid]]) } else if('site' in props) { const site = props.site const title = props.title || props.name.replaceAll(/\n/g,' ') const message = { action: 'doInternalLink', keepLineup:event.shiftKey, pageKey, context: [site], title } console.log(message) window.opener.postMessage(message) } }) }) } window.dogroup = event => { const s=window.target.getBoundingClientRect(); const nids = [...window.target.getElementsByClassName('node')] .filter(node=>{ const r=node.getBoundingClientRect(); return r.x>s.x && r.x+r.width<s.x+s.width && r.y>s.y && r.y+r.height<s.y+s.height}) .map(node=>+node.querySelector('title').textContent) console.log('nids',...nids) const rids = window.graph.rels .map((rel,i) => nids.includes(rel.from) && nids.includes(rel.to) ? i : null) .filter(rid => rid !== null) console.log('rids',...rids) const nodes = nids.map(nid => { const node = window.graph.nodes[nid] node.in = node.in.map(rid => rids.indexOf(rid)).filter(rid => rid >= 0) node.out = node.out.map(rid => rids.indexOf(rid)).filter(rid => rid >= 0) return node }) const rels = rids.map(rid => { const rel = window.graph.rels[rid] rel.from = nids.indexOf(rel.from) rel.to = nids.indexOf(rel.to) return rel }) const graph = new Graph(nodes,rels) const name = new Date().toLocaleTimeString() console.log(name,graph) beam.push({name,graph,label:'groups'}) refreshBeam() } window.dopreview = event => { const aspects = [...window.beamlist.querySelectorAll('input[type=checkbox]:checked')] .map(e => beam[+e.value]) console.log(aspects) const title = 'New Aspects for Solo' const story = [ {type:'paragraph',text:'This page saved from previous page in wiki.'}, {type:'graphviz',text:window.dot}, {type:'solo',text:'INCLUDED',aspects}] const page = {title,story} const date = Date.now() const asCopy = obj => JSON.parse(JSON.stringify(obj)) for (const item of page.story) item.id ||= (Math.random()*10**20).toFixed(0) page.journal ||= [{type:'create', date, item:asCopy(page)}] const message = { action: 'showResult', keepLineup:event.shiftKey, pageKey, page } console.log(message) window.opener.postMessage(message) } window.dodownload = event => { const poems = [...window.beamlist.querySelectorAll('input[type=checkbox]:checked')] .map(e => beam[+e.value]) const complex = composite(poems) const filename = poems .map(poem => poem.name.replace(/[^a-zA-Z0-9]/g,'')) .filter(uniq).sort().join('-') + '.graph.json' console.log({poems,complex,filename}) // download(complex.graph.stringify(null,2),filename,'application/json') } </script> </body> </html>