<!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>