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