var panopticon; class Panopticon { constructor() { console.log( "Init panopticon" ); this.languages = [] this.hugveys = new Vue( { el: "#status", data: { uptime: 0, languages: [], hugveys: [] }, methods: { time_passed: function( hugvey, property ) { console.log( "property!", Date( hugvey[property] * 1000 ) ); return moment( Date( hugvey[property] * 1000 ) ).fromNow(); }, timer: function(hugvey, property) { return panopticon.stringToHHMMSS( hugvey[property] ); }, loadNarrative: function( code, file ) { return panopticon.loadNarrative( code, file ); }, pause: function(hv) { hv.status = "loading"; return panopticon.pause(hv.id); }, resume: function(hv) { hv.status = "loading"; return panopticon.resume(hv.id); }, restart: function(hv) { hv.status = "loading"; return panopticon.restart(hv.id); }, change_lang: function(hv, lang_code) { hv.status = "loading"; return panopticon.change_language(hv.id, lang_code); } } } ); this.socket = new ReconnectingWebSocket( "ws://localhost:8888/ws", null, { debug: false, reconnectInterval: 3000 } ); this.graph = new Graph(); this.socket.addEventListener( 'open', ( e ) => { this.send( { action: 'init' } ); } ); this.socket.addEventListener( 'close', function( e ) { console.log( 'Closed connection' ); } ); this.socket.addEventListener( 'message', ( e ) => { let msg = JSON.parse( e.data ); if ( typeof msg['alert'] !== 'undefined' ) { alert( msg['alert'] ); } if ( typeof msg['action'] === 'undefined' ) { console.error( "not a valid message: " + e.data ); return; } switch ( msg['action'] ) { case 'status': console.debug(msg); this.hugveys.uptime = this.stringToHHMMSS( msg['uptime'] ); this.hugveys.languages = msg['languages']; this.languages = msg['languages']; this.hugveys.hugveys = msg['hugveys']; break; } } ); } send( msg ) { if ( this.socket.readyState == WebSocket.OPEN ) { this.socket.send( JSON.stringify( msg ) ); } else { console.error( "Socket not open: ", this.socket.readyState ); } } getStatus() { // console.log('get status', this, panopticon); panopticon.send( { action: 'get_status' } ); } init() { setInterval( this.getStatus, 3000 ); } stringToHHMMSS( string ) { var sec_num = parseInt( string, 10 ); // don't forget the second param var hours = Math.floor( sec_num / 3600 ); var minutes = Math.floor(( sec_num - ( hours * 3600 ) ) / 60 ); var seconds = sec_num - ( hours * 3600 ) - ( minutes * 60 ); if ( hours < 10 ) { hours = "0" + hours; } if ( minutes < 10 ) { minutes = "0" + minutes; } if ( seconds < 10 ) { seconds = "0" + seconds; } return hours + ':' + minutes + ':' + seconds; } loadNarrative( code, file ) { if(typeof file == 'undefined') { for (let lang of this.languages) { if (lang['code'] == code) { file = lang['file']; } } } let req = new XMLHttpRequest(); let graph = this.graph; req.addEventListener( "load", function( e ) { graph.loadData( JSON.parse( this.response ), code ); // console.log(, e); } ); req.open( "GET", "/local/" + file ); req.send(); } resume( hv_id ) { this.send( { action: 'resume', hugvey: hv_id } ) } pause( hv_id ) { this.send( { action: 'play', hugvey: hv_id } ) } restart( hv_id ) { this.send( { action: 'restart', hugvey: hv_id } ) } change_language( hv_id, lang_code ) { this.send( { action: 'change_language', hugvey: hv_id, lang_code: lang_code } ); } } window.addEventListener( 'load', function() { panopticon = new Panopticon(); panopticon.init(); } ); 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.language_code = 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.diversions = []; // 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 audioSpan = crel( 'span', { 'title': msg['audio'] ? msg['audio']['file'] : "", 'class': "label-value", }, msg['audio'] ? msg['audio']['original_name'] : "" ); if(msg['audio']) { audioSpan.appendChild(crel( 'audio', {'controls': 'controls'}, crel('source', {'src':msg['audio']['file']}) )); } 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 ) ), crel( 'label', crel( 'span', 'Audio' ), audioSpan, crel( 'input', { 'type': 'file', 'name': 'audio', 'accept': '.wav,.ogg,.mp3', 'on': { 'change': function(e) { audioSpan.innerHTML = "..."; panopticon.graph.saveJson(msg['@id'], e.target, function(e2){ console.log(e); audioSpan.innerHTML = e.target.files[0].name + "*"; // reload graph: console.log('reload', panopticon.graph.language_code); panopticon.loadNarrative(panopticon.graph.language_code); }); // console.log(this,e); } } } ) ) ); 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 g = this; let directionEl = crel('div', { 'class': 'direction ' + (direction['source'] == msg ? 'dir-to' : 'dir-from'), 'on': { 'mouseover': function(e) { console.log('over', direction['@id']); directionEl.classList.add('dir-highlight'); document.getElementById(direction['@id']).classList.add('dir-highlight'); }, 'mouseout': function(e) { console.log('& out ', direction['@id']); directionEl.classList.remove('dir-highlight'); document.getElementById(direction['@id']).classList.remove('dir-highlight'); } } }, crel( 'h3', {'title': direction['@id']}, direction['source'] == msg ? `To ${direction['target']['@id']}`: `From ${direction['source']['@id']}` ), crel('div', { 'class':'btn btn--delete', 'on': { 'click': ( e ) => g.rmDirection( direction ) } }, 'delete') ); 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", { 'class': "divToggle", 'on': { 'click': function(e) { this.classList.toggle('opened'); } } }, "Create New Condition" ), crel('div', {'class': 'divToggle-target'}, 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": this.language_code.substring( 0, 2 ) + "-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": this.language_code.substring( 0, 2 ) + "-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": this.language_code.substring( 0, 2 ) + "-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.diversions] 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 ); } downloadJson() { if ( !this.language_code ) { alert( "Make sure to load a language first" ) } 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 ); } } saveJson( msg_id, fileInputElement, callback ) { if ( !this.language_code ) { alert( "Make sure to load a language first" ) } let formData = new FormData(); formData.append( "language", this.language_code ); if ( msg_id ) { formData.append( "message_id", msg_id ); formData.append( "audio", fileInputElement.files[0] ); } let blob = new Blob( [this.getJsonString()], { type: "application/json" } ); formData.append( "json", blob ); console.log( formData ); var request = new XMLHttpRequest(); request.open( "POST", "http://localhost:8888/upload" ); if(callback) { request.addEventListener( "load", callback); } request.send( formData ); } loadData( data, language_code ) { this.language_code = language_code; 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.diversions = this.data.filter(( node ) => node['@type'] == 'Diversion' ); document.getElementById('current_lang').innerHTML = ""; document.getElementById('current_lang').appendChild(crel('span', { 'class': 'flag-icon ' + this.language_code })); // 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 textId = newNodeG.append( "text" ).attr( 'class', 'msg_id' ); let textContent = newNodeG.append( "text" ).attr( 'class', 'msg_txt' ); let statusIcon = newNodeG.append( "image" ) .attr( 'class', 'status_icon' ) .attr( 'x', '-10' ) .attr( 'y', '10' ) .attr( 'width', '20' ) .attr( 'height', '20' ) ; // 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" ); } ); link.attr('id', (l) => l['@id']); // console.log('c'); let formatText = ( t ) => { if ( t.length > this.maxChars ) { return t.substr( 0, this.maxChars - 3 ) + '...'; } else { return t; } }; node.selectAll( "text.msg_id" ).text( d => d['@id'] ); node.selectAll( "text.msg_txt" ).text( d => formatText( `${d['text']}` ) ); node.selectAll( "image.status_icon" ).attr('xlink:href', d => d['audio'] ? '' : '/images/music-broken.svg'); // 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(); } }