var panopticon; class Panopticon { constructor() { console.log( "Init panopticon" ); this.languages = [] // this.selectedHugvey = null; this.hugveys = new Vue( { el: "#interface", data: { uptime: 0, languages: [], hugveys: [], selectedId: null, }, methods: { time_passed: function( hugvey, property ) { return moment( Date( hugvey[property] * 1000 ) ).fromNow(); }, timer: function(hugvey, property) { return panopticon.stringToHHMMSS( hugvey[property] ); }, loadNarrative: function( code, file ) { panopticon.hugveys.selectedId = null; 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); }, showHugvey: function(hv) { panopticon.hugveys.selectedId = hv.language ? hv.id : null; panopticon.updateSelectedHugvey(); } } } ); 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']; if(this.hugveys.selectedId) { this.updateSelectedHugvey(); } break; } } ); } updateSelectedHugvey() { let hv = null; if(this.hugveys.selectedId) { hv = this.getHugvey(this.hugveys.selectedId); if(hv.language && this.graph.language_code != hv.language) { this.loadNarrative(hv.language); } } this.graph.updateHugveyStatus(hv); } getHugvey(id) { for(let hv of this.hugveys.hugveys) { if(hv.id == id) { return hv; } } return null; } 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: 'pause', 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 } ); } playFromSelected(msg_id) { if(!this.hugveys.selectedId) { alert('No hugvey selected'); } else { this.send({ action: 'play_msg', hugvey: this.hugveys.selectedId, msg_id: msg_id }) } } } 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 ) { if ( e.which == "16" ) { // shift graph.controlDown = true; document.body.classList.add( 'controlDown' ); } } ); document.addEventListener( 'keyup', function( e ) { if ( e.which == "16" ) { // shift 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. 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 ); } getAudioUrlForMsg(msg) { let isVariable = msg['text'].includes('$') ? '1' : '0'; return `http://localhost:8888/voice?text=${encodeURIComponent(msg['text'])}&variable=${isVariable}&filename=0`; } showMsg( msg ) { let msgEl = document.getElementById( 'msg' ); msgEl.innerHTML = ""; if(msg == null){ return; } let startAttributes = { 'name': msg['@id'] + '-start', // 'readonly': 'readonly', 'type': 'checkbox', 'on': { 'change': this.getEditEventListener() } } if ( msg['start'] == true ) { startAttributes['checked'] = 'checked'; } let audioSrcEl = crel('source', {'src': this.getAudioUrlForMsg(msg)}); let audioSpan = crel( 'span', { 'title': msg['audio'] ? msg['audio']['file'] : "", 'class': "label-value", }, crel( 'audio', {'controls': 'controls'}, audioSrcEl ) ); let msgInfoEl = crel( 'div', { 'class': 'msg__info' }, crel('div', { 'class':'btn btn--delete', 'on': { 'click': function(e) { if(confirm(`Are you sure you want to remove message ${msg['@id']}`)) { panopticon.graph.rmMsg(msg); panopticon.graph.showMsg(null); } } } }, 'delete'), 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(function(){ audioSrcEl.src = panopticon.graph.getAudioUrlForMsg(msg); audioSrcEl.parentElement.load(); }) } } ) ), 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, e2); 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); } } } ) ), crel( 'label', crel( 'span', { "title": "The time after the reply in which one can still interrupt to continue speaking" }, 'Afterrun time' ), crel( 'input', { 'name': msg['@id'] + '-afterrunTime', 'value': msg['afterrunTime'], 'type': 'number', 'on': { 'change': this.getEditEventListener() } } ) ) ); msgEl.appendChild( msgInfoEl ); if(panopticon.hugveys.selectedId) { let playEl = crel( 'div', {'class': 'play'}, crel( 'div', { 'class': 'btn btn--play', 'on': { 'click': function (e) { console.log('go save'); panopticon.graph.saveJson(null, null, function(){ console.log('saved, now play'); panopticon.playFromSelected(msg['@id']); }); } } }, "Save & play on #" + panopticon.hugveys.selectedId ) ); msgEl.appendChild(playEl); } // 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, crel('div',{ 'class': 'btn btn--create', 'on': { 'click': function(e) { panopticon.graph.createConnectedMsg(msg); } } }, 'Create new message'), ...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) { directionEl.classList.add('dir-highlight'); document.getElementById(direction['@id']).classList.add('dir-highlight'); }, 'mouseout': function(e) { directionEl.classList.remove('dir-highlight'); document.getElementById(direction['@id']).classList.remove('dir-highlight'); } } }, crel( 'h3', {'title': direction['@id']}, direction['source'] == msg ? `To ${direction['target']['text']}`: `From ${direction['source']['text']}` ), crel('div', { 'class':'btn btn--delete', 'on': { 'click': ( e ) => { if(confirm("Do you want to remove this direction and its conditions?")) { g.rmDirection( direction ); } } } }, 'disconnect') ); 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'] ), crel('div', { 'class':'btn btn--delete', 'on': { 'click': ( e ) => { if(confirm("Do you want to remove this condition?")) { console.log('remove condition for direction', condition, direction); panopticon.graph.rmCondition( condition, direction ); } } } }, 'delete'), ...this.getConditionInputsForType(condition['type'], condition['@id'], condition['vars']) ) 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() { return { 'timeout': { 'seconds': { 'type': 'number', 'value': 10, 'min': 0, 'step': 0.1, 'unit': "s" }, 'onlyIfNoReply': { 'type': 'checkbox', label: "Only if no reply", "title": "This timeout is only used if the participant doesn't say a word. If the participant starts speaking within the time of this timeout condition, only other conditions are applicable." } }, 'replyContains': { 'delays.0.minReplyDuration': { 'type': 'number', 'value': 0, 'min': 0, 'step': 0.1, 'label': 'Delay 1 - reply duration', 'unit': "s", 'readonly': 'readonly' }, 'delays.0.waitTime': { 'type': 'number', 'value': 3, 'min': 0, 'step': 0.1 , 'label': 'Delay 1 - wait time', 'unit': "s" }, 'delays.1.minReplyDuration': { 'type': 'number', 'value': 5, 'min': 0, 'step': 0.1, 'label': 'Delay 2 - reply duration', 'unit': "s" }, 'delays.1.waitTime': { 'type': 'number', 'value': 1, 'min': 0, 'step': 0.1, 'label': 'Delay 2 - time', 'unit': "s" }, 'delays.2.minReplyDuration': { 'type': 'number', 'value': 10, 'min': 0, 'step': 0.1, 'label': 'Delay 3 - reply duration', 'unit': "s" }, 'delays.2.waitTime': { 'type': 'number', 'value': 0, 'min': 0, 'step': 0.1, 'label': 'Delay 3 - time', 'unit': "s" }, 'regex': { 'value': '','placeholder': "match any input" }, 'instantMatch': { 'value': '', 'title': "When matched, don't wait for reply to finish. Instantly take this direction.", 'type':'checkbox' }, } }; } getConditionInputsForType( type, conditionId, values ) { let inputs = []; let vars = this.getConditionTypes()[type]; for ( let v in vars ) { let attr = vars[v]; attr['name'] = typeof conditionId == 'undefined' ? v : `${conditionId}-vars.${v}`; if(typeof values != 'undefined') { let value = this._getValueForPath(v, values); if(attr['type'] == 'checkbox' ) { if(value) attr['checked'] = 'checked'; } attr['value'] = typeof value == 'undefined' ? "": value; attr['on'] = { 'change': this.getEditEventListener() } ; } else { // console.log(attr); } inputs.push( crel( 'label', crel( 'span', { 'title': attr.hasOwnProperty('title') ? attr['title'] : "" }, attr.hasOwnProperty('label') ? attr['label'] : v ), crel( 'input', attr ) // crel('span', {'class': 'label-unit'}, attr.hasOwnProperty('unit') ? attr['unit'] : "" ) ) ); } return inputs; } fillConditionFormForType( conditionForm, type, values ) { conditionForm.innerHTML = ""; let inputs = this.getConditionInputsForType(type); for(let i of inputs) { conditionForm.appendChild(i); } } _getValueForPath(path, vars) { path = path.split( '.' ); // use vars.test to set ['vars']['test'] = value let v = vars; let result = null; for ( let i = 0; i < path.length; i++ ) { if(!isNaN(parseInt(path[i])) && isFinite(path[i])) { // is int, use array, instead of obj path[i] = parseInt(path[i]); } v = v[path[i]]; if(i == path.length - 1) { result = v; } if(typeof v == 'undefined') { break; } } return result; } /** * Save an array path (string) with a value to an object. Used to turn * strings into nested arrays * @param string path * @param {any} value * @param array|object vars */ _formPathToVars(path, value, vars) { path = path.split( '.' ); // use vars.test to set ['vars']['test'] = value let res = vars; for ( let i = 0; i < path.length; i++ ) { if ( i == ( path.length - 1 ) ) { res[path[i]] = value; } else { if(!isNaN(parseInt(path[i+1])) && isFinite(path[i+1])) { // is int, use array, instead of obj path[i+1] = parseInt(path[i+1]); } if(typeof res[path[i]] == 'undefined') { res[path[i]] = typeof path[i+1] == 'number' ? [] : {} } res = res[path[i]]; } } return vars; } 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' ); // checkboxes to true/false let defs = g.getConditionTypes()[type]; console.log(defs); for(let field in defs) { console.log(field); if(defs[field]['type'] == 'checkbox') { console.info('configure checkbox', field); form.set(field, form.has(field)); } } let vars = {}; for ( var pair of form.entries() ) { // FormData only has strings & blobs, we want booleans: if(pair[1] === 'true') pair[1] = true; if(pair[1] === 'false') pair[1] = false; vars = g._formPathToVars(pair[0], pair[1], vars); } // TODO: checkboxes console.log("Createded", vars); 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; } /** * remove condition from the graph or merely from the given direction * @param {any} condition The condition to remove * @param {any} direction if given, only remove from this direction */ rmCondition( condition, direction ) { let id = condition['@id']; // TODO if ( typeof direction != 'undefined' ) { let pos = direction['conditions'].indexOf(id); console.log('delete', id, 'on direction'); if(pos > -1) { direction['conditions'].splice(pos, 1); } for(let dir of this.directions) { // console.log('check if condition exists for dir', dir) if(dir['conditions'].indexOf(id) > -1) { console.log("Condition still in use"); this.updateFromData(); this.build(); this.updateMsg(); return; } } console.log('No use, remove', condition) this._rmNode( condition ); } else { for(let dir of this.directions) { let pos = dir['conditions'].indexOf(id); if(pos > -1) { dir['conditions'].splice(pos, 1); } } console.log('remove condition?', id) this._rmNode( condition ); } this.updateMsg(); } 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, "afterrunTime": 0.5, } this.data.push( msg ); this.updateFromData(); this.build(); this.selectMsg(msg); 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 ); // todo, remove orphaned conditions } createMsg() { this.addMsg(); this.build(); } createConnectedMsg(sourceMsg) { let newMsg = this.addMsg(); this.addDirection(sourceMsg, newMsg); this.build(); // reselect so that overview is updated this.selectMsg(newMsg); } 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(callback) { let graph = this; let el = function( e ) { console.info("Changed", e); let parts = e.srcElement.name.split( '-' ); let field = parts.pop(); let id = parts.join('-'); let node = graph.getNodeById( id ); let path = field.split( '.' ); // use vars.test to set ['vars']['test'] = value var res = node; let value = e.srcElement.value if(e.srcElement.type == 'checkbox') { value = e.srcElement.checked; } for ( var i = 0; i < path.length; i++ ) { if ( i == ( path.length - 1 ) ) { res[path[i]] = value; } else { res = res[path[i]]; } } // node[field] = e.srcElement.value; graph.build(); if(typeof callback !== 'undefined'){ callback(); } } 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 ); } console.info("Jsonified graph:",d); 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.info("Save json", 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 })); this.distances = this.calculateDistancesFromStart(); // save state; this.saveState(); } updateHugveyStatus(hv) { let els = document.getElementsByClassName('beenHit'); while(els.length > 0) { els[0].classList.remove('beenHit'); } if(!hv || typeof hv['history'] == 'undefined') { return; } for(let msg of hv['history']['messages']) { document.getElementById(msg[0]['id']).classList.add('beenHit'); } for(let msg of hv['history']['directions']) { document.getElementById(msg[0]['id']).classList.add('beenHit'); } } 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'] ).strength(0) ) // .force( "charge", d3.forceManyBody().strength( 100 ) ) // .force( "center", d3.forceCenter( this.width / 2, this.height / 2 ) ) .force( "collide", d3.forceCollide( this.nodeSize * 2.3 ) ) .force( "forceX", d3.forceX(function(m){ let fx = panopticon.graph.distances[m['@id']] !== null ? panopticon.graph.distances[m['@id']] * panopticon.graph.nodeSize * 4 : 0 console.log('fx', m['@id'], panopticon.graph.distances[m['@id']], fx); return fx; }).strength(50)) .force( "forceY", d3.forceY(m => panopticon.graph.distances[m['@id']] !== null ? 0 : panopticon.graph.nodeSize * 3 ).strength(30)) ; this.simulation.velocityDecay(.98); // 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 ) ) ; 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']); 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(); } calculateDistancesFromStart() { let starts = this.messages.filter( m => m.hasOwnProperty('start') && m['start'] == true); if (starts.length < 1) { console.error("No start set"); return; } let startMsg = starts[0]; //initiate distances let distances = {}; for(let msg of this.messages) { distances[msg['@id']] = msg === startMsg ? 0 : null; } let directionsPerMsg = {}; console.log("dir", this.directions); for(let direction of this.directions) { let from = typeof direction['source'] == "string" ? direction['source'] : direction['source']['@id']; let to = typeof direction['target'] == "string" ? direction['target'] : direction['target']['@id']; if(!directionsPerMsg.hasOwnProperty(from)) { directionsPerMsg[from] = []; } directionsPerMsg[from].push(to); } let traverseMsg = function(msgId, depth) { if(!directionsPerMsg.hasOwnProperty(msgId)) { // end of trail return; } for(let childMsgId of directionsPerMsg[msgId]) { if(distances[childMsgId] === null || distances[childMsgId] > depth) { distances[childMsgId] = depth; traverseMsg(childMsgId, depth+1); } else { // apparently, there is a loop. Don't traverse it. } } } traverseMsg(startMsg['@id'], 1); return distances; } }