<!DOCTYPE html> <html lang="en" dir="ltr"> <head> <meta charset="utf-8"> <title>Pillow Talk - Narrative Builder</title> <script src="https://d3js.org/d3.v5.min.js"></script> <script type="text/javascript"> // https://raw.githubusercontent.com/KoryNunn/crel/master/crel.min.js !function(n,e){"object"==typeof exports?module.exports=e():"function"==typeof define&&define.amd?define(e):n.crel=e()}(this,function(){function n(a){var d,s=arguments,p=s[1],y=2,m=s.length,x=n[o];if(a=n[u](a)?a:c.createElement(a),m>1){if((!e(p,r)||n[f](p)||Array.isArray(p))&&(--y,p=null),m-y==1&&e(s[y],"string"))a.textContent=s[y];else for(;y<m;++y)null!==(d=s[y])&&l(a,d);for(var v in p)if(x[v]){var A=x[v];e(A,t)?A(a,p[v]):a[i](A,p[v])}else e(p[v],t)?a[v]=p[v]:a[i](v,p[v])}return a}var e=function(n,e){return typeof n===e},t="function",r="object",i="setAttribute",o="attrMap",f="isNode",u="isElement",c=document,a=function(n){return n instanceof Node},d=function(n){return n instanceof Element},l=function(e,t){if(Array.isArray(t))return void t.map(function(n){l(e,n)});n[f](t)||(t=c.createTextNode(t)),e.appendChild(t)};return n[o]={},n[u]=d,n[f]=a,e(Proxy,"undefined")||(n.proxy=new Proxy(n,{get:function(e,t){return!(t in n)&&(n[t]=n.bind(null,t)),n[t]}})),n}); crel.attrMap['on'] = function(element, value) { for (var eventName in value) { element.addEventListener(eventName, value[eventName]); } }; </script> <style media="screen"> body{ margin:0; overflow: hidden; font-family: "Noto Sans", sans-serif; } svg{ width:100vw; height: 100vh; cursor: grab; } svg:active{ cursor: grabbing; } circle{ cursor: pointer; fill: rgb(119, 97, 142); } .startMsg circle{ fill: lightseagreen; } .endMsg circle{ fill: lightslategray; } .orphanedMsg{ fill: lightcoral; } text{ text-anchor: middle; font-size: 11pt; font-family: sans-serif; fill: white; } line{ marker-end: url('#arrowHead'); stroke-width: 2px; stroke: black; } line.link--noconditions{ stroke-dasharray: 5 4; stroke: red; } label::after { content: ''; clear: both; display: block; } label{ width:100%; font-weight:bold; display: block; margin: 0 -10px; padding: 5px 10px; } label input,label select{ float: right; } label:nth-child(odd){ background-color: rgba(255,255,255,0.3); } #msg{ position: absolute; top:0; right:0; width: 30%; max-height:100%; overflow-y: auto; } #msg .msg__info, #msg .directions > div{ padding: 10px; margin-bottom: 10px; background:lightgray; } #opener{ display: flex; width: 100%; height: 100vh; justify-content: center; align-items: center; flex-direction: column; } #nodes g:hover circle, .selectedMsg circle { stroke: lightgreen; stroke-width: 8; } .controlDown #nodes g:hover circle, .secondaryMsg circle { stroke: lightgreen; stroke-width: 5; stroke-dasharray: 10 3; } .condition h4{ text-align: center; } .condition + .condition::before { content: "OR"; display: block; border-bottom: solid 2px; height: 10px; margin-bottom: 15px; text-align: center; text-shadow: 2px 2px 2px lightgray,-2px 2px 2px lightgray,2px -2px 2px lightgray,-2px -2px 2px lightgray; } .condition--add{ /* text-align: center; */ } .btn{ padding: 5px; background:lightgray; border-radius: 5px; display: inline-block; cursor: pointer; } .btn:hover{ background: lightblue; } </style> </head> <body> <div id="opener"> <h1>Hugvey</h1> <h3>Select a narrative json file</h3> <input id='fileOpener' type="file" /> <div> <input id='fileLoad' type="submit" value="Load file" /> <input id='stateLoad' type="button" value="Load last state" /> </div> </div> <div id="msg"></div> <div id="controls"> <div id='btn-save' class='btn'>Save JSON</div> <div id='btn-addMsg' class='btn'>New Message</div> </div> <svg id='graph' viewbox="0 0 1280 1024" preserveAspectRatio="xMidYMid"> <defs> <marker markerHeight="8" markerWidth="8" refY="0" refX="12" viewBox="0 -6 16 12" preserveAspectRatio="none" orient="auto" id="arrowHead"><path d="M0,-6L16,0L0,6" fill="black"></path></marker> </defs> <g id='container'> </g> </svg> </body> <script type="text/javascript"> class Graph{ constructor() { this.width = 1280; this.height = 1024; this.nodeSize = 80; this.maxChars = 16; this.svg = d3.select('#graph'); this.container = d3.select('#container'); this.selectedMsg = null; this.messages = []; // initialise empty array. For the simulation, make sure we keep the same array object this.directions = []; // initialise empty array. For the simulation, make sure we keep the same array object this.conditions = []; // initialise empty array. For the simulation, make sure we keep the same array object this.interruptions = []; // initialise empty array. For the simulation, make sure we keep the same array object let graph = this; this.controlDown = false; document.addEventListener('keydown', function(e){ console.log(e); if(e.which == "17") { graph.controlDown = true; document.body.classList.add('controlDown'); } }); document.addEventListener('keyup', function(e){ console.log(e); if(e.which == "17") { graph.controlDown = false; document.body.classList.remove('controlDown'); } }); let c = this.container; let zoomed = function(){ c.attr("transform", d3.event.transform); } this.svg.call(d3.zoom() .scaleExtent([1 / 2, 8]) .on("zoom", zoomed)); this.nodesG = this.container.append("g") .attr("id", "nodes") this.linkG = this.container.append("g") .attr("id", "links"); document.getElementById('btn-save').addEventListener('click', function(e){ graph.saveJson(); }); document.getElementById('btn-addMsg').addEventListener('click', function(e){ graph.createMsg(); }); } clickMsg(msg) { // event when a message is clicked. console.log(msg); if(this.controlDown) { this.secondarySelectMsg(msg); } else { this.selectMsg(msg); } } secondarySelectMsg(msg) { if(this.selectedMsg !== null) { this.addDirection(this.selectedMsg, msg); } else { console.error('No message selected as Source'); } } selectMsg(msg) { let selectedEls = document.getElementsByClassName('selectedMsg'); while(selectedEls.length > 0){ selectedEls[0].classList.remove('selectedMsg'); } document.getElementById(msg['@id']).classList.add('selectedMsg'); this.selectedMsg = msg; this.showMsg(msg); } updateMsg() { // used eg. after a condition creation. this.showMsg(this.selectedMsg); } showMsg(msg) { let msgEl = document.getElementById('msg'); msgEl.innerHTML = ""; let startAttributes = { 'name': msg['@id'] + '-start', 'disabled': true, 'type': 'checkbox', 'on': { 'change': this.getEditEventListener() } } if(msg['start'] == true) { startAttributes['checked'] = 'checked'; } let msgInfoEl = crel('div', {'class': 'msg__info'}, crel('h1', {'class':'msg__id'}, msg['@id']), crel('label', crel('span', 'Text'), crel('input', { 'name': msg['@id'] + '-text', 'value': msg['text'], 'on': { 'change': this.getEditEventListener() } } ) ), crel('label', crel('span', 'Start'), crel('input', startAttributes) ) ); msgEl.appendChild(msgInfoEl); // let directionHEl = document.createElement('h2'); // directionHEl.innerHTML = "Directions"; let fromDirections =[] , toDirections = []; for(let direction of this.getDirectionsTo(msg)) { toDirections.push(this.getDirectionEl(direction, msg)); } for(let direction of this.getDirectionsFrom(msg)) { fromDirections.push(this.getDirectionEl(direction, msg)); } let directionsEl = crel('div', {'class': 'directions'}, crel('h2', 'Directions'), ...toDirections, ...fromDirections ); msgEl.appendChild(directionsEl); } getDirectionEl(direction, msg) { let directionEl = document.createElement('div'); if(direction['source'] == msg) { directionEl.innerHTML = `<h3>To ${direction['target']['@id']}</h3>`; } else { directionEl.innerHTML = `<h3>From ${direction['source']['@id']}</h3>`; } let del = document.createElement('div'); del.innerHTML = "delete"; del.classList.add("deleteBtn"); let g = this; del.addEventListener('click', (e) => g.rmDirection(direction)); directionEl.appendChild(del); // TODO; conditions for(let conditionId of direction['conditions']) { let condition = this.getNodeById(conditionId); directionEl.appendChild(this.getEditConditionFormEl(condition, direction)); } directionEl.appendChild(this.getAddConditionFormEl(direction)); return directionEl; } getEditConditionFormEl(condition, direction) { let conditionEl = crel('div', {'class': 'condition condition--edit'}, crel('h4', {'title': condition['@id']}, condition['type']) ) let labelLabel = document.createElement('label'); labelLabel.innerHTML = "Description"; let labelInput = crel('input',{ 'name': `${condition['@id']}-label`, 'value': typeof condition['label'] == 'undefined' ? "" : condition['label'], 'on': { 'change': this.getEditEventListener() } }); labelLabel.appendChild(labelInput); conditionEl.appendChild(labelLabel); for(let v in condition['vars']) { let varLabel = document.createElement('label'); varLabel.innerHTML = v; let varInput = document.createElement('input'); if(v == 'seconds') { varInput.type = 'number'; } varInput.name = `${condition['@id']}-vars.${v}`; varInput.value = condition['vars'][v]; varInput.addEventListener('change', this.getEditEventListener()); varLabel.appendChild(varInput); conditionEl.appendChild(varLabel); } return conditionEl; } getConditionTypes() { if(typeof this.conditionTypes === 'undefined') { // type: vars: attribtes for crel() this.conditionTypes = { 'timeout': { 'seconds': {'type': 'number', 'value': 10, 'min':0, 'step': 0.1} }, 'replyContains': { 'regex': {'value': '.+'} } } } return this.conditionTypes; } fillConditionFormForType(conditionForm, type) { conditionForm.innerHTML = ""; let vars = this.getConditionTypes()[type]; for(let v in vars){ let attr = vars[v]; attr['name'] = v; conditionForm.appendChild( crel('label', crel('span', v), crel('input', attr) ) ); } } getAddConditionFormEl(direction) { let optionEls = []; let types = this.getConditionTypes(); for(let type in types) { optionEls.push(crel('option', type)); } let conditionForm = crel('div', {'class': 'condition--vars'}); let g = this; let addConditionEl = crel('div', {'class': 'condition condition--add'}, crel('form', { 'on': { 'submit': function(e) { e.preventDefault(); let form = new FormData(e.target); console.log('submit', form); let type = form.get('type'); form.delete('type'); let label = form.get('label'); form.delete('label'); let vars = {}; for(var pair of form.entries()) { vars[pair[0]] = pair[1]; } g.addConditionForDirection(type, label, vars, direction); } } }, crel("h4", "Create New Condition"), crel("label", crel('span', "Type"), crel('select', { 'name': 'type', 'on': { 'change': function(e){ g.fillConditionFormForType(conditionForm, e.target.value); } }}, optionEls), ), crel("label", crel('span', "Description"), crel('input', {'name': 'label'}) ), conditionForm, crel('input', { 'type':'submit', 'value':'create' }) ) ); this.fillConditionFormForType(conditionForm, optionEls[0].value); return addConditionEl; } rmConditionFromDirection(condition, direction) { let id = condition['@id']; // TODO if(typeof direction != 'undefined') { } this._rmNode(id); } getConditionEl(condition) { let conditionEl = document.createElement('div'); return conditionEl; } getDirectionsFrom(msg) { return this.directions.filter(d => d['source'] == msg); } getDirectionsTo(msg) { return this.directions.filter(d => d['target'] == msg); } addMsg() { let msg = { "@id": "n" + Date.now().toString(36), "@type": "Msg", "text": "New", "start": false } this.data.push(msg); this.updateFromData(); this.build(); return msg; } rmMsg(msg) { let invalidatedDirections = this.directions.filter(d => d['source'] == msg || d['target'] == msg); console.log('invalidated', invalidatedDirections); for(let dir of invalidatedDirections) { let i = this.data.indexOf(dir); this.data.splice(i, 1); } this._rmNode(msg); } _rmNode(node) { // remove msg/direction/condition/etc let i = this.data.indexOf(node); this.data.splice(i, 1); this.updateFromData(); this.build(); return this.data; } addConditionForDirection(type, label, vars, direction) { let con = this.addCondition(type, label, vars, true); direction['conditions'].push(con['@id']); this.updateFromData(); this.build(); this.updateMsg(); } addCondition(type, label, vars, skip) { let con = { "@id": "c" + Date.now().toString(36), "@type": "Condition", "type": type, "label": label, "vars": vars } this.data.push(con); if(skip !== true) { this.updateFromData(); this.build(); } return con; } addDirection(source, target) { let dir = { "@id": "d" + Date.now().toString(36), "@type": "Direction", "source": source, "target": target, "conditions": [] } this.data.push(dir); this.updateFromData(); this.build(); return dir; } rmDirection(dir) { this._rmNode(dir); } createMsg() { this.addMsg(); this.build(); } getNodeById(id) { return this.data.filter(node => node['@id'] == id)[0]; } /** * Use wrapper method, because for event handlers 'this' will refer to * the input object */ getEditEventListener(){ let graph = this; let el = function(e){ let parts = e.srcElement.name.split('-'); let id = parts[0], field = parts[1]; console.log(this, graph); let node = graph.getNodeById(id); let path = field.split('.'); // use vars.test to set ['vars']['test'] = value var res=node; for (var i=0;i<path.length;i++){ if(i == (path.length -1)) { console.log('last', path[i]); res[path[i]] = e.srcElement.value; } else { res=res[path[i]]; } } // node[field] = e.srcElement.value; graph.build(); } return el; } getJsonString() { // recreate array to have the right order of items. this.data = [...this.messages, ...this.conditions, ...this.directions, ...this.interruptions] let d = []; let toRemove = ['sourceX', 'sourceY', 'targetX', 'targetY', 'x','y', 'vx','vy'] for(let node of this.data) { let n = {}; console.log(node['source']); for (let e in node) { if (node.hasOwnProperty(e) && toRemove.indexOf(e) == -1 ) { if(this.data.indexOf(node[e]) != -1) { n[e] = node[e]['@id']; } else { n[e] = node[e]; } } } d.push(n); } return JSON.stringify(d); } saveJson() { var blob = new Blob([this.getJsonString()], {type: 'application/json'}); if(window.navigator.msSaveOrOpenBlob) { window.navigator.msSaveBlob(blob, "pillow_talk.json"); } else{ var elem = window.document.createElement('a'); elem.href = window.URL.createObjectURL(blob); elem.download = "pillow_talk.json"; document.body.appendChild(elem); elem.click(); document.body.removeChild(elem); } } loadData(data) { this.data = data; this.updateFromData(); this.build(true); } updateFromData() { this.messages = this.data.filter((node) => node['@type'] == 'Msg'); this.directions = this.data.filter((node) => node['@type'] == 'Direction'); this.conditions = this.data.filter((node) => node['@type'] == 'Condition'); this.interruptions = this.data.filter((node) => node['@type'] == 'Interruption'); // save state; this.saveState(); } saveState() { window.localStorage.setItem("lastState", this.getJsonString()); } hasSavedState() { return window.localStorage.getItem("lastState") !== null; } loadFromState() { this.loadData(JSON.parse(window.localStorage.getItem("lastState"))); } build(isInit) { this.simulation = d3.forceSimulation(this.messages) .force("link", d3.forceLink(this.directions).id(d => d['@id'])) .force("charge", d3.forceManyBody().strength(-1000)) .force("center", d3.forceCenter(this.width / 2, this.height / 2)) .force("collide", d3.forceCollide(this.nodeSize*2)) ; // Update existing nodes let node = this.nodesG .selectAll("g") .data(this.messages, n => n['@id']) ; // Update existing nodes let newNode = node.enter(); let newNodeG = newNode.append("g") .attr('id', d => d['@id']) .call(d3.drag(this.simulation)) .on('click', function(d){ this.clickMsg(d); }.bind(this)) ; console.log('a'); let circle = newNodeG.append("circle") .attr('r', this.nodeSize) // .text(d => d.id) ; let text = newNodeG.append("text") ; // remove node.exit().remove(); node = node.merge(newNodeG); // for all existing nodes: node.attr('class', msg => { let classes = []; if( this.selectedMsg == msg) classes.push('selectedMsg'); if( msg['start'] == true ) classes.push('startMsg'); if(this.getDirectionsFrom(msg).length < 1) { classes.push('endMsg'); if(this.getDirectionsTo(msg).length < 1) classes.push('orphanedMsg'); } return classes.join(' '); }) let link = this.linkG .selectAll("line") .data(this.directions) ; let newLink = link.enter() .append("line") ; //remove link.exit().remove(); link = link.merge(newLink); link.attr('class', l => { return `link ` + (l['conditions'].length == 0 ? "link--noconditions" : "link--withconditions"); }); // console.log('c'); let formatText = (t) => { if(t.length > this.maxChars) { return t.substr(0, this.maxChars - 3) + '...'; } else { return t; } }; node.selectAll("text").text(d => formatText(`(${d['@id']}) ${d['text']}`)); // console.log('q'); // // TODO: update text // let text = newNodeG.append("text") // // .attr('stroke', "black") // .text(d => formatText(`(${d['@id']}) ${d['text']}`)) // // .attr('title', d => d.label) // ; let n = this.nodesG; this.simulation.on("tick", () => { link .each(function(d){ let sourceX, targetX, midX, dx, dy, angle; // This mess makes the arrows exactly perfect. // thanks to http://bl.ocks.org/curran/9b73eb564c1c8a3d8f3ab207de364bf4 if( d.source.x < d.target.x ){ sourceX = d.source.x; targetX = d.target.x; } else if( d.target.x < d.source.x ){ targetX = d.target.x; sourceX = d.source.x; } else if (d.target.isCircle) { targetX = sourceX = d.target.x; } else if (d.source.isCircle) { targetX = sourceX = d.source.x; } else { midX = (d.source.x + d.target.x) / 2; if(midX > d.target.x){ midX = d.target.x; } else if(midX > d.source.x){ midX = d.source.x; } else if(midX < d.target.x){ midX = d.target.x; } else if(midX < d.source.x){ midX = d.source.x; } targetX = sourceX = midX; } dx = targetX - sourceX; dy = d.target.y - d.source.y; angle = Math.atan2(dx, dy); // Compute the line endpoint such that the arrow // is touching the edge of the node rectangle perfectly. d.sourceX = sourceX + Math.sin(angle) * this.nodeSize; d.targetX = targetX - Math.sin(angle) * this.nodeSize; d.sourceY = d.source.y + Math.cos(angle) * this.nodeSize; d.targetY = d.target.y - Math.cos(angle) * this.nodeSize; }.bind(this)) .attr("x1", function(d) { return d.sourceX; }) .attr("y1", function(d) { return d.sourceY; }) .attr("x2", function(d) { return d.targetX; }) .attr("y2", function(d) { return d.targetY; }); node.attr("transform", d => `translate(${d.x},${d.y})`); // .attr("cy", d => d.y); }); // this.simulation.alpha(1); // this.simulation.restart(); if(typeof isInit != 'undefined' && isInit) { for (let i = 0, n = Math.ceil(Math.log(this.simulation.alphaMin()) / Math.log(1 - this.simulation.alphaDecay())); i < n; ++i) { this.simulation.tick(); } } return this.svg.node(); } } var graph = new Graph(); var openerDiv = document.getElementById("opener"); var fileOpenerBtn = document.getElementById('fileOpener'); var openerSubmit = document.getElementById('fileLoad'); var loadFileFromInput = function (inputEl) { if(inputEl.files && inputEl.files[0]){ var reader = new FileReader(); reader.onload = function (e) { var output=e.target.result; let j = JSON.parse(output); graph.loadData(j); openerDiv.parentElement.removeChild(openerDiv); };//end onload() reader.readAsText(inputEl.files[0]); } } fileOpenerBtn.addEventListener('change', function(){ loadFileFromInput(this); }) if(fileOpenerBtn.files.length) { openerSubmit.addEventListener('click', function() { loadFileFromInput(fileOpener); }); } else { openerSubmit.parentElement.removeChild(openerSubmit); } let loadStateBtn = document.getElementById('stateLoad'); if(graph.hasSavedState()) { loadStateBtn.addEventListener('click', function() { graph.loadFromState(); openerDiv.parentElement.removeChild(openerDiv); }); } else { loadStateBtn.parentElement.removeChild(loadStateBtn); } </script> </html>