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); }, finish: function(hv) { hv.status = "loading"; return panopticon.finish(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.eventDataSet = new vis.DataSet([ {content: '.', start: new Date(), type: 'point', group: 1} ]); this.timeline = false; document.getElementById('toggleTimeline').addEventListener('click', function(){ document.body.classList.toggle('showTimeline'); }); this.socket.addEventListener( 'open', ( e ) => { this.send( { action: 'init' } ); } ); // request close before unloading window.addEventListener('beforeunload', function(){ panopticon.socket.close(); }); 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; } console.debug(msg); switch ( msg['action'] ) { case 'status': 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(); } if(!this.timeline) { console.log('init timeline'); let groups = []; for(let hid of msg['hugvey_ids']) { groups.push({id: parseInt(hid), content: 'Hugvey #'+hid}); this.eventDataSet.add({content: 'initiate', start: new Date(), type: 'point', group: parseInt(hid)}) } let dataGroups = new vis.DataSet(groups); let options = { // 'rollingMode': {'follow': true, 'offset': .8 } }; console.log('groups', dataGroups, groups, options); this.timeline = new vis.Timeline(document.getElementById('timeline'), this.eventDataSet, dataGroups, options); // let startDate = new Date(); startDate.setMinutes(startDate.getMinutes()-1); let endDate = new Date(); endDate.setMinutes(endDate.getMinutes()+20); setTimeout(function(){ panopticon.timeline.setWindow(startDate, endDate); }, 500); console.log(startDate, endDate); this.timelineInterval = setInterval(function(){panopticon.timeline.moveTo(new Date());}, 1000); } break; case 'log': let hv_id = parseInt(msg['id']); if(this.timeline) { // {'action': 'log', 'id':hugvey_id, 'type': items[0], 'info', 'args'} let d, parts; switch(msg['type']){ case 'message': // info: en-njsm8bwbd "Are you up for a conversation?" parts = msg['info'].trim().split(' '); let msgId = parts.shift(); let msgUuid = parts.shift(); let msgEvent = parts.shift(); let msgContent = parts.join(' '); let mId = 'm-'+msgUuid+'-'+hv_id; d = this.eventDataSet.get(mId); console.log(msgId, msgEvent, msgContent); if(d !== null && msgEvent == 'done'){ d['end'] = new Date(); this.eventDataSet.update(d); console.log('update', d); } else { this.eventDataSet.add({id: mId, content: msgContent, title: `${msgContent} (${msgId})`, start: new Date(), group: hv_id, 'className': 'message'}); } break; case 'speaking': // start/content/end parts = msg['info'].trim().split(' '); let info = parts.shift(); let id = parts.shift(); let content = parts.join(' '); let scId = 'sc-'+id+'-'+hv_id; if(info.startsWith('start')){ this.eventDataSet.add({content: info, start: new Date(), type: 'point', group: hv_id, 'className': 'speech'}); } if(info.startsWith('content')){ d = this.eventDataSet.get(scId); if(d !== null){ console.log('alter'); d['content'] = content; d['end']= new Date(); d['title'] = content; this.eventDataSet.update(d); } else { console.log('add'); this.eventDataSet.add({id: scId, content: content, title: content, start: new Date(), group: hv_id, 'className': 'speech'}); } } if(info.startsWith('end')){ d = this.eventDataSet.get(scId); if(d !== null){ d['end'] = new Date(); this.eventDataSet.update(d); } } break; case 'story': // 'info': 'start'/'finished' this.eventDataSet.add({content: msg['type'] +': ' + msg['info'] + ': ' + msg['args'], start: new Date(), type: 'point', group: hv_id, 'className': 'story'}); break; case 'condition': case 'direction': // don't draw these :-0 break; default: this.eventDataSet.add({content: msg['type'] +': ' + msg['info'] + ': ' + msg['args'], start: new Date(), type: 'point', group: hv_id}); break; } } 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', selected_id: panopticon.hugveys.selectedId } ); } 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 } ); } finish( hv_id ) { this.send( { action: 'finish', 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 / 16, 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(); } ); document.getElementById( 'btn-diversions' ).addEventListener( 'click', function( e ) { graph.showDiversions(); } ); document.getElementById( 'btn-audio' ).addEventListener( 'click', function( e ) { graph.showAudioFiles(); } ); } 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'; let lang = panopticon.graph.language_code; return `http://localhost:8888/voice?text=${encodeURIComponent(msg['text'])}&variable=${isVariable}&lang=${lang}&filename=0`; } getNumericId(prefix) { let id, i = 0; let hasId= function(a, id) { for(let i of a){ if(i['@id'] == id) { return true; } } return false; } do { id = prefix + i; i++; } while(hasId(this.data, id)) return id; } createDiversion(type) { let div = { "@id": this.getNumericId(this.language_code.substring( 0, 2 ) + `-div-${type}#`), '@type': 'Diversion', 'type': type, 'params': {} } if(type == 'no_response') { div['params']['consecutiveSilences'] = 3; div['params']['timesOccured'] = 0; div['params']['returnAfterStrand'] = true; div['params']['msgId'] = ""; } else if(type == 'repeat') { div['params']['regex'] = "can you repeat that\\?"; } else { console.log("invalid type", type); alert('invalid type for diversion'); } this.data.push( div ); this.updateFromData(); this.build(); this.showDiversions(); return msg; } deleteDiversion(div) { this._rmNode( div ); this.showDiversions( ); } showDiversions( ) { let msgEl = document.getElementById( 'msg' ); msgEl.innerHTML = ""; let divsNoResponse =[], divsRepeat = []; for(let div of this.diversions) { if(div['type'] == 'no_response') { let returnAttrs = { 'type': 'checkbox', 'on': { 'change': (e) => div['params']['returnAfterStrand'] = e.target.checked } } if(div['params']['returnAfterStrand']) { returnAttrs['checked'] = 'checked'; } let msgOptions = [crel('option',"")]; let starts = this.messages.filter( m => m.hasOwnProperty('start') && m['start'] == true); for(let startMsg of starts) { let optionParams = {}; if(div['params']['msgId'] == startMsg['@id']) { optionParams['selected'] = 'selected'; } msgOptions.push(crel('option', optionParams , startMsg['@id'])); } divsNoResponse.push(crel( 'div', { 'class': 'diversion', 'on': { 'mouseover': function(e) { if(div['params']['msgId']) document.getElementById(div['params']['msgId']).classList.add('selectedMsg'); }, 'mouseout': function(e) { if(div['params']['msgId']) document.getElementById(div['params']['msgId']).classList.remove('selectedMsg'); } } }, crel('h3', div['@id']), crel( 'div', { 'class':'btn btn--delete', 'on': { 'click': (e) => this.deleteDiversion(div) } }, 'Delete diversion'), crel('label', 'Consecutive Silences', crel('input', { 'type': 'number', 'value': div['params']['consecutiveSilences'], 'on': { 'change': (e) => div['params']['consecutiveSilences'] = parseInt(e.target.value) } }) ), crel('label', 'On n-th instance', crel('input', { 'type': 'number', 'value': div['params']['timesOccured'], 'on': { 'change': (e) => div['params']['timesOccured'] = parseInt(e.target.value) } }) ), crel('label', 'Return to point of departure afterwards', crel('input', returnAttrs) ), crel('label', 'Go to (start message)', crel('select', {'on': { 'change': (e) => div['params']['msgId'] = e.target.value }}, ...msgOptions) ) )); } if(div['type'] == 'repeat'){ divsRepeat.push(crel( 'div', {'class': 'diversion'}, crel('h3', div['@id']), crel( 'div', { 'class':'btn btn--delete', 'on': { 'click': (e) => this.deleteDiversion(div) } }, 'Delete diversion'), crel('label', 'Regex', crel('input', { 'type': 'text', 'value': div['params']['regex'], 'on': { 'change': (e) => div['params']['regex'] = e.target.value } }) ) )); } } console.log(divsNoResponse, divsRepeat); let divEl = crel( 'div', { 'id': 'diversions' }, crel('h1', 'Configure Diversions'), crel('div', crel('h2', 'In case of No Response'), ...divsNoResponse, crel('div', { 'class': 'btn', 'on': { 'click': (e) => this.createDiversion('no_response') } }, 'New case for no_response' ) ), crel('div', crel('h2', 'Request repeat'), ...divsRepeat, crel('div', { 'class': 'btn', 'on': { 'click': (e) => this.createDiversion('repeat') } }, 'New case for repeat' ) ) ); msgEl.appendChild(divEl); } showAudioFiles( ) { let audioFilesEl = crel('div',{ 'id':'audioFiles' }, crel( 'div', { 'class':'btn btn-close', 'on': { 'click': function() { audioFilesEl.parentNode.removeChild(audioFilesEl) } } }, 'close' )); for(let msg of panopticon.graph.messages) { audioFilesEl.appendChild(crel( 'audio', {'controls':'controls'}, crel('source', {'src': msg['audio'] ? msg['audio']['file'] : panopticon.graph.getAudioUrlForMsg(msg)}) )) } document.getElementById("interface").appendChild(audioFilesEl); } 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 beginningAttributes = { 'name': msg['@id'] + '-beginning', // 'readonly': 'readonly', 'type': 'checkbox', 'on': { 'change': this.getEditEventListener() } } if ( msg['beginning'] == true ) { beginningAttributes['checked'] = 'checked'; } let params = {}; if(msg.hasOwnProperty('params')) { params = msg['params']; } else { msg['params'] = {}; } let audioSrcEl = crel('source', {'src': msg['audio'] ? msg['audio']['file'] : this.getAudioUrlForMsg(msg)}); // console.log(msg['audio']); let audioSpan = crel( 'span', { 'title': msg['audio'] ? msg['audio']['file'] : "", 'class': "label-value", }, crel( 'audio', {'controls': 'controls'}, audioSrcEl ), crel('div', msg['audio'] ? crel( 'div', crel('div', { 'class':'btn btn--delete', 'on': { 'click': function(e) { e.stopPropagation(); e.preventDefault(); panopticon.graph.getNodeById(msg['@id'])['audio'] = null; panopticon.graph.showMsg(msg); } } }, 'del'), "uploaded" ) : 'Auto-generated') ); let msgInfoEl = crel( 'div', { 'class': 'msg__info' }, crel('div', { 'class':'btn btn--delete btn--delete-msg', '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'] + ` (${panopticon.graph.distances[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', 'Beginning' ), crel( 'input', beginningAttributes ) ), 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', 'step': "0.1", 'value': msg['afterrunTime'], 'type': 'number', 'on': { 'change': this.getEditEventListener() } } ) ), crel( 'label', crel( 'span', { "title": "Playback volume factor" }, 'Volume factor' ), crel( 'input', { 'name': msg['@id'] + '-params.vol', 'value': params.hasOwnProperty('vol') ? params['vol'] : 1, 'step': "0.1", 'type': 'number', 'on': { 'change': this.getEditEventListener() } } ) ), crel( 'label', crel( 'span', { "title": "Playback tempo factor" }, 'Tempo factor' ), crel( 'input', { 'name': msg['@id'] + '-params.tempo', 'value': params.hasOwnProperty('tempo') ? params['tempo'] : 1, 'step': "0.1", 'min': "0.1", 'type': 'number', 'on': { 'change': this.getEditEventListener() } } ) ), crel( 'label', crel( 'span', { "title": "Playback pitch factor" }, 'Pitch factor' ), crel( 'input', { 'name': msg['@id'] + '-params.pitch', 'value': params.hasOwnProperty('pitch') ? params['pitch'] : 0, 'step': "0.1", 'type': 'number', 'on': { 'change': this.getEditEventListener() } } ) ), // color for beter overview crel( 'label', crel( 'span', { "title": "Color - for your eyes only" }, 'Color' ), crel( 'input', { 'name': msg['@id'] + '-color', 'value': msg.hasOwnProperty('color') ? msg['color'] : '#77618e', 'type': 'color', '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." }, 'needsReply': { 'type': 'checkbox', label: "Reply needed", "title": "If checked, the timeout is counted if met. Used by consecutive-timeouts diversions." }, }, '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' }, }, 'variable': { 'variable': { 'value': '','placeholder': "Variable name (without $)" }, 'notSet': { "label": "Not set", 'value': '', 'title': "Match if the variable is _not_ set.", '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(skipRebuild) { 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 ); console.log("skip or not to skip?", skipRebuild); if(typeof skipRebuild == 'undefined' || !skipRebuild) { 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 ); let skipDistances; // orphaned target and source has no other destinations. We can copy the vertical position: if(this.getDirectionsFrom( source ).length < 1 && this.getDirectionsFrom( target ).length < 1 && this.getDirectionsTo( target ).length < 1) { skipDistances = true; let distance = this.distances[source['@id']]; let d = [distance[0] + 1, distance[1]]; // create a distance based on source's position // this saves us from running the slow calculateDistancesFromStart this.distances[target['@id']] = d; } else { skipDistances = false; } this.updateFromData(skipDistances); this.build(); return dir; } rmDirection( dir ) { this._rmNode( dir ); // todo, remove orphaned conditions } createMsg() { this.addMsg(); this.build(); } createConnectedMsg(sourceMsg) { console.time('createConnected'); console.time("Add"); let newMsg = this.addMsg(true); // skipRebuild = true, as addDirection() already rebuilds the graph this.getNodeById(newMsg['@id']).y = this.getNodeById(sourceMsg['@id']).y; if(this.getNodeById(sourceMsg['@id']).hasOwnProperty('color')){ this.getNodeById(newMsg['@id']).color = this.getNodeById(sourceMsg['@id']).color } console.timeEnd("Add"); console.time("direction"); this.addDirection(sourceMsg, newMsg); console.timeEnd("direction"); console.time("build"); // this.build(); // build is already done in addDirection() console.timeEnd("build"); // reselect so that overview is updated console.time("Select"); this.selectMsg(newMsg); console.timeEnd("Select"); console.timeEnd('createConnected'); } 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'] let toRemove = ['sourceX', 'sourceY', 'targetX', 'targetY', '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(skipDistances) { 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 })); if(typeof skipDistances == 'undefined' || !skipDistances) { 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 * 1.5 ).strength(3) ) .force( "forceX", d3.forceX(function(m){ let fx = panopticon.graph.distances[m['@id']] !== null ? panopticon.graph.distances[m['@id']][0] * panopticon.graph.nodeSize * 4 : 0 // console.log('fx', m['@id'], panopticon.graph.distances[m['@id']], fx); return fx; }).strength(50)) .force( "forceY", d3.forceY(function(m){ // if(panopticon.graph.distances[m['@id']] !== null ) // console.log(panopticon.graph.distances[m['@id']][1]); let fy = panopticon.graph.distances[m['@id']] !== null ? panopticon.graph.distances[m['@id']][1] * panopticon.graph.nodeSize * 3: 0 // console.log('fx', m['@id'], panopticon.graph.distances[m['@id']], fx); return fy; }).strength(50)) // .force( "forceY", d3.forceY(m => panopticon.graph.distances[m['@id']] !== null ? 0 : panopticon.graph.nodeSize * 3 ).strength(30)) ; this.simulation.velocityDecay(.99); // 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'] ) .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( ' ' ); } ) .on(".drag", null) .call( d3.drag( this.simulation ) .on("start", function(d){ if (!d3.event.active) panopticon.graph.simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }) .on('drag', function(d){ d.fx = d3.event.x; d.fy = d3.event.y; }) .on("end", function(d){ if (!d3.event.active) panopticon.graph.simulation.alphaTarget(0); d.fx = null; d.fy = null; }) // .container(document.getElementById('container')) ); node.select('circle').attr('style', (d) => 'fill: ' + (d.hasOwnProperty('color') ? d['color'] : '#77618e')); 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(); 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() { console.time('calculateDistancesFromStart'); let starts = this.messages.filter( m => m.hasOwnProperty('start') && m['start'] == true); if (starts.length < 1) { console.error("No start set"); return; } //initiate distances let distances = {}; for(let msg of this.messages) { // distances[msg['@id']] = msg === startMsg ? 0 : null; distances[msg['@id']] = null; } let targetsPerMsg = {}; let sourcesPerMsg = {}; // 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(!targetsPerMsg.hasOwnProperty(from)) { targetsPerMsg[from] = []; } targetsPerMsg[from].push(to); if(!sourcesPerMsg.hasOwnProperty(to)) { sourcesPerMsg[to] = []; } sourcesPerMsg[to].push(from); } let traverseMsg = function(msgId, depth, goingDown, yPos) { let msgsPerMsg = goingDown ? targetsPerMsg : sourcesPerMsg; // console.log(goingDown, msgId, depth); if(!msgsPerMsg.hasOwnProperty(msgId)) { // end of trail return yPos; } let i = 0, y =0; for(let childMsgId of msgsPerMsg[msgId]) { if(distances[childMsgId] !== null){ continue; } if(distances[childMsgId] === null || (goingDown && distances[childMsgId][0] > depth)) { if(distances[childMsgId] === null) { if(i > 0){ yPos++; } i++; console.log('set for id', childMsgId, goingDown, depth, yPos); distances[childMsgId] = [depth, yPos]; } else{ y++; } // console.log(goingDown, childMsgId, depth); yPos = traverseMsg(childMsgId, goingDown ? (depth+1) : (depth - 1), goingDown, yPos); } else if(!goingDown && distances[childMsgId][0] < depth) { if(childMsgId == 'en-njsgkr4az') { console.log('set for id', childMsgId, goingDown); } if(distances[childMsgId] === null) { distances[childMsgId] = [depth, yPos]; } // console.log('a', depth); yPos = traverseMsg(childMsgId, depth - 1, goingDown, yPos); } else { // apparently, there is a loop. Don't traverse it. } } // if( i == 0 && y == 1) { // // we reached an item that branches back into the tree // return yPos -1; // } // console.log('yPos',msgId,yPos); return yPos; } let yPos = 0; console.time('step1'); for(let startMsg of starts) { console.time('start: '+startMsg['@id']); if(distances[startMsg['@id']] === null) { distances[startMsg['@id']] = [0, yPos]; } yPos = traverseMsg(startMsg['@id'], 1 , true, yPos); yPos += 1; console.timeEnd('start: '+startMsg['@id']); } console.timeEnd('step1'); console.time('step2'); // now we have the formal tree, lets try to polish the rest: for(let msgId in distances) { console.time('polish: '+ msgId); if(distances[msgId] === null) { continue; } // let's see if there are parent nodes that are not in the distances array // traverse up and see whether we encounter anything new traverseMsg(msgId, distances[msgId][0] -1, false, distances[msgId][1]) console.timeEnd('polish: '+ msgId); } console.timeEnd('step2'); // let additionalsDepth = 0; //// now the secondary strands: // for(let msgId in distances) { // if(distances[msgId] !== null || sourcesPerMsg.hasOwnProperty(msgId)) { // // it is already calculated, or it has a parent node (which we should traverse instead) // continue; // } // distances[msgId] = additionalsDepth; // traverseMsg(msgId, additionalsDepth+1, true); // // } console.timeEnd("calculateDistancesFromStart"); return distances; } } // //class Timeline { // constructor(el, hugvey_ids) { // this.el = el; // this.logbook = [] // this.hugvey_ids = hugvey_ids; // // this.el.innerHTML = ""; // for(id of this.hugvey_ids) { // this.el.appendChild(crel( // 'div', { // // } // )); // } // } // // log(msg) { //// {"action": "log", "id": "3", "type": "story", "info": " start"} // console.log('log!', msg); // } //}