diff options
| author | Alon Levy <alon@pobox.com> | 2014-12-16 17:37:42 +0200 |
|---|---|---|
| committer | Alon Levy <alon@pobox.com> | 2014-12-16 17:37:42 +0200 |
| commit | ade19de410e0a789a921ec1666b278b2a64176d6 (patch) | |
| tree | c4c54b106de93609ca7bb70a20fd4be696715dbe /src/client | |
| parent | c7d026b1d504323a8c61d51b726e5e8d2337fc4b (diff) | |
moving files around after repository merger
Diffstat (limited to 'src/client')
30 files changed, 4620 insertions, 0 deletions
diff --git a/src/client/ScrollTo.js b/src/client/ScrollTo.js new file mode 100644 index 00000000..a015f08c --- /dev/null +++ b/src/client/ScrollTo.js @@ -0,0 +1,17 @@ +$.fn.scrollTo = function( target, options, callback ){ + if(typeof options == 'function' && arguments.length == 2){ callback = options; options = target; } + var settings = $.extend({ + scrollTarget : target, + offsetTop : 50, + duration : 500, + easing : 'swing' + }, options); + return this.each(function(){ + var scrollPane = $(this); + var scrollTarget = (typeof settings.scrollTarget == "number") ? settings.scrollTarget : $(settings.scrollTarget); + var scrollY = (typeof scrollTarget == "number") ? scrollTarget : scrollTarget.offset().top + scrollPane.scrollTop() - parseInt(settings.offsetTop); + scrollPane.animate({scrollTop : scrollY }, parseInt(settings.duration), settings.easing, function(){ + if (typeof callback == 'function') { callback.call(this); } + }); + }); +}
\ No newline at end of file diff --git a/src/client/app.js b/src/client/app.js new file mode 100644 index 00000000..39acdde7 --- /dev/null +++ b/src/client/app.js @@ -0,0 +1,31 @@ +(function() { + var lib_path = '../lib/'; + var config = { + paths: { + jquery: lib_path + 'jquery', + 'jquery-ui': lib_path + 'jquery-ui', + 'd3': lib_path + 'd3/d3', + FileSaver: lib_path + 'FileSaver', + caret: lib_path + 'caret', + autocomplete: lib_path + 'autocomplete', + Bacon: lib_path + 'Bacon', + } + } + + config.urlArgs = (typeof local_config != 'undefined') && local_config.urlArgs; + +if (window.is_node) { + // Testing path only + console.log('app: running under node'); + config.baseUrl = '../src/'; + window.rhizi_require_config = config; +} else { + // Main app path + require.config(config); + + requirejs(['main'], function(main) { + console.log('starting rhizi logic'); + main.main(); + }); +} +}()); diff --git a/src/client/buttons.js b/src/client/buttons.js new file mode 100644 index 00000000..860c8573 --- /dev/null +++ b/src/client/buttons.js @@ -0,0 +1,82 @@ +"use strict" + +define(['jquery', 'FileSaver', 'rz_core'], function ($, saveAs, rz_core) { +$('.tutorial').click(function(){}); + +var key="#47989379"; + + +$('.save a').click(function(){ + var json = rz_core.graph.save_to_json(); + console.log('saving to local storage ' + json.length + ' bytes'); + localStorage.setItem(key, json); +}); + +$('.export').click(function() { + var json = rz_core.graph.save_to_json(); + var filename = 'graph.json'; + var blob = new Blob([json], {type: 'application/json'}); + console.log('saving ' + json.length + ' bytes to ' + filename); + saveAs(blob, filename); +}); + +$('.url-copy a').click(function() { + var json = rz_core.graph.save_to_json(); + // TODO use jquery BBQ $.param({json: json}); + var encoded = document.location.origin + '/?json=' + encodeURIComponent(json); + window.prompt('Copy to clipboard: Ctrl-C, Enter (or Cmd-C for Mac)', encoded); +}); + +var really_load = function() { + if (!rz_core.graph.empty()) { + return confirm('All unsaved changes will be deleted, are you sure?'); + } + return true; +} + +$('.file-import').on('change', function(event) { + var file = event.target.files[0]; + var reader; + + if (!really_load()) { + return; + } + if (file === undefined) { + return; + } + console.log(file); + reader = new FileReader(); + reader.onload = (function(theFile) { + return function(e) { + var result = e.target.result; + if (e.target.readyState === FileReader.DONE) { + console.log('done reading ' + theFile.name); + console.log('got #' + result.length + ' bytes in ' + typeof(result)); + rz_core.load_from_json(result); + } + } + })(file); + reader.readAsText(file, "text/javascript"); +}); + +$('.local-storage-load a').click(function(){ + if (!really_load()) { + return; + } + var json_blob = localStorage.getItem(key) + rz_core.load_from_json(json_blob); +}); + +var logout_button = $('#logout-button'); +logout_button.click(function() { + $.ajax({ type: "POST", url: '/logout'}); // server should redirect back to /login +}); + +$('a.save-history').click(function() { + if (rz_core.graph.history === undefined) { + throw "History is undefined"; + } + rz_core.graph.history.save_to_file(); +}); +return {'buttons': 'nothing here'}; +}); // define diff --git a/src/client/consts.js b/src/client/consts.js new file mode 100644 index 00000000..32167811 --- /dev/null +++ b/src/client/consts.js @@ -0,0 +1,25 @@ +"use strict" + +define(function() { + + var nodetypes = ["person", "club", "skill", "interest", "third-internship-proposal", "internship"]; + + var description = { + person: 'A person in CRI - student or teacher', + club: 'A shared club or project within the CRI', + skill: 'Ability or expertise you possess', + interest: 'Scientific skill or domain expertise you wish you had', + 'third-internship-proposal': 'Create this to submit your third internship proposal', + internship: "Title of first or second internship you've done" + }; + + // TODO: enums, sometime + return { + KEYSTROKE_WHERE_EDIT_NODE: 'keystroke_where_edit_node', + KEYSTROKE_WHERE_DOCUMENT: 'keystroke_where_document', + KEYSTROKE_WHERE_TEXTANALYSIS: 'keystroke_where_textanalysis', + INPUT_WHERE_TEXTANALYSIS: 'input_where_textanalysis', + nodetypes: nodetypes, + description: description, + }; +}); diff --git a/src/client/drag_n_drop.js b/src/client/drag_n_drop.js new file mode 100644 index 00000000..da166ad4 --- /dev/null +++ b/src/client/drag_n_drop.js @@ -0,0 +1,31 @@ +define(['jquery', 'rz_core'], function($, rz_core) { + +function init() { +console.log('rhizi: init drag-n-drop'); +$(document).on('drop', function(e) { + e.stopPropagation(); + e.preventDefault(); + var files = e.originalEvent.dataTransfer.files; + var file = files[files.length - 1]; + var fr = new FileReader(); + fr.onload = function() { + if (fr.readyState != 2) { + console.log('drop: error: reading from file failed'); + } else { + console.log('loading dropped file'); + rz_core.load_from_json(fr.result); + } + } + fr.readAsText(file); + return false; +}); +$(document).on('dragover', function (e) +{ + e.stopPropagation(); + e.preventDefault(); + return false; +}); +}; +return {'init': init }; + +}); // define diff --git a/src/client/history.js b/src/client/history.js new file mode 100644 index 00000000..ab864d1c --- /dev/null +++ b/src/client/history.js @@ -0,0 +1,149 @@ +"use strict" +// Once upon a time we shall have a versioned property graph from which history +// will be one extractable aspect, much like a git for graphs. Now we just have +// a plain list of events for a specific user. + +// Enums and chrome don't play along well. Object.freeze I guess? actually rhizi code cuases +// exceptions but that shouldn't break the console, as evidenced by the '__commandLineAPI is not defined' +// error below. +// +// Uncaught TypeError: Can't add property addednodes, object is not extensible rz_core.js:4 +// Uncaught TypeError: Can't add property text, object is not extensible textanalysis.js:3 +// Uncaught TypeError: Can't add property key, object is not extensible buttons.js:5 +// Uncaught ReferenceError: sentence is not defined robot.js:11 +// Resource interpreted as Font but transferred with MIME type application/font-sfnt: "http://localhost:8000/external/Lato300.ttf". jquery.js:2 +// > document +// ReferenceError: __commandLineAPI is not defined +//var ActionEnum = Enum(); + +define(['jquery', 'FileSaver', 'consts', 'rz_bus'], + function($, saveAs, consts, rz_bus) { + +/* user - username (string) + * svg - svg element for catching zoom events (jquery DOMNode wrapper) + */ +function History(user, graph, transform_element) { + var that = this; + this.records = []; + this.user = user; + this.transform_element = transform_element; + graph.diffBus.onValue(function (obj) { + return that.record_graph_diff(obj) + }); + rz_bus.ui_key.onValue(that.record_keystrokes.bind(that)); + rz_bus.ui_input.onValue(that.record_input.bind(that)); + // XXX create zoom behavior - then proof to event name change + $(window).on('wheel.history', function(obj) { + that.record_zoom(obj); + return true; + }); +} + +var ACTION_KEYSTROKES = 'ACTION_KEYSTROKES'; +var ACTION_INPUT = 'ACTION_INPUT'; +var ACTION_GRAPH_DIFF = 'ACTION_GRAPH_DIFF'; +var ACTION_ZOOM = 'ACTION_ZOOM'; + +var KEYSTROKE_WHERE_TEXTANALYSIS = 'KEYSTROKE_WHERE_TEXTANALYSIS'; +var KEYSTROKE_WHERE_DOCUMENT = 'KEYSTROKE_WHERE_DOCUMENT'; +var KEYSTROKE_WHERE_EDIT_NODE = 'KEYSTROKE_WHERE_EDIT_NODE'; + +History.prototype.record = function(action, d) +{ + if (d === undefined || action === undefined) { + throw "Invalid arguments"; + } + d['action'] = action; + d['user'] = this.user; + d['timestamp'] = new Date(); + this.records.push(d); + $('.history-timeline').html('<pre>' + JSON.stringify(d) + '</pre>'); +}; + +function svg_extract_translate_and_scale(e) +{ + // See: http://stackoverflow.com/questions/10349811/how-to-manipulate-translate-transforms-on-a-svg-element-with-javascript-in-chrom + // Using the regexp option right now, did only firefox testing 36 + var str = e.attributes['transform'].value; + var parts = /translate\(\s*([^\s,)]+)[ ,]([^\s,)]+)/.exec(str); + var scale = /scale\(\s*([^\s)]+)\)/.exec(str); + if (scale) { + var x = parts[1], y = parts[2]; + return {scale:+scale[1], translate: [+x, +y]}; + } else { + return {scale:1.0, translate: [0.0, 0.0]}; + } +} + +History.prototype.record_zoom = function(d) +{ + var transform = svg_extract_translate_and_scale(this.transform_element); + + if (transform === undefined) { + console.log('record_zoom: bug: transform_element has no transform attribute'); + return; + } + this.record(ACTION_ZOOM, {transform: transform}); +} + +History.prototype.save_to_file = function() +{ + var json = JSON.stringify(this.records, function (k, v) { return v; }, 2); + + saveAs(new Blob([json], {type: 'application/json'}), 'history.json'); +}; + +History.prototype.clear = function() +{ + this.records = []; +} + +History.prototype.record_graph_diff = function(obj) +{ + this.record(ACTION_GRAPH_DIFF, { + nodes: {add: obj.nodes && obj.nodes.add, remove: obj.nodes && obj.nodes.remove, + change: obj.nodes && obj.nodes.change}, + links: {add: obj.links && obj.links.add, remove: obj.links && obj.links.remove, + change: obj.links && obj.links.change}, + }); +} + +History.prototype.record_keystrokes = function(obj) +{ + var where = obj.where, + keys = obj.keys; + + if (where === undefined || keys === undefined || keys.length === undefined || + keys.length <= 0) { + console.log("invalid arguments"); + return; + } + keys = keys.filter(function(k) { return k !== undefined; }); + if (keys.length == 0) { + return; + } + this.record(ACTION_KEYSTROKES, { + keys: keys, + where: where + }); +} + +History.prototype.record_input = function(obj) +{ + var where = obj.where, + input = obj.input; + + if (where === undefined || input === undefined || input.length === undefined || typeof input !== 'string') { + console.log('invalid arguments'); + return; + } + this.record(ACTION_INPUT, {where: where, input: input}); +} + +return { + History:History, + KEYSTROKE_WHERE_TEXTANALYSIS:KEYSTROKE_WHERE_TEXTANALYSIS, + KEYSTROKE_WHERE_DOCUMENT:KEYSTROKE_WHERE_DOCUMENT, + KEYSTROKE_WHERE_EDIT_NODE:KEYSTROKE_WHERE_EDIT_NODE +}; +}); // define diff --git a/src/client/main.js b/src/client/main.js new file mode 100644 index 00000000..863d0440 --- /dev/null +++ b/src/client/main.js @@ -0,0 +1,94 @@ +define(['textanalysis.ui', 'textanalysis', 'buttons', 'history', 'drag_n_drop', 'robot', 'model/core', 'rz_config', 'rz_core', 'view/selection', 'util', 'view/completer'], +function(textanalysis_ui, textanalysis, buttons, history, drag_n_drop, robot, model_core, rz_config, rz_core, selection, util, completer) { + + function expand(obj){ + if (!obj.savesize) { + obj.savesize = obj.size; + } + obj.size = Math.max(obj.savesize, obj.value.length); + } + + this.main = function() { + var json, + search = $('#search'), + search_completer = completer(search, $('#search-suggestion'), + {triggerStart:' ', triggerEnd:' '}); + + console.log('Rhizi main started'); + search_completer.options.plug(textanalysis.suggestions_options); + drag_n_drop.init(); + $('#editname').onkeyup = function() { expand(this); }; + $('#editlinkname').onkeyup = function() { expand(this); }; + $('#textanalyser').onkeyup = function() { expand(this); }; + + textanalysis_ui.main(); + + json = util.getParameterByName('json'); + if (json) { + rz_core.load_from_json(json); + } + if (util.getParameterByName('debug')) { + $(document.body).addClass('debug'); + rz_core.graph.set_user('fakeuser'); + } + + document.body.onkeyup = function(e) { + var key = (e.key || (e.charCode && String.fromCharCode(e.charCode)) + || (e.which && String.fromCharCode(e.which))).toLowerCase(); + + if (e.altKey && e.ctrlKey && key == 'i') { + $('#textanalyser').focus(); + } + if (e.altKey && e.ctrlKey && key == 'o') { + search.focus(); + } + if (e.ctrlKey && key == 'z' && e.target.nodeName !== 'INPUT') { + // TODO: rz_core.graph.undo(); + } + }; + // TODO: move me somewhere + function search_on_submit() { + var text = search[0].value.trim(), + r; + + try { + r = new RegExp(text.replace(/ /, '|'), 'i'); + } catch (e) { + return; // don't clear selection either + } + if (text.length > 0) { + selection.byVisitors(function (n) { return n.name.match(r); }); + } else { + selection.clear(); + } + rz_core.update_view__graph(false); + }; + search.on('input', search_on_submit); + search.on('keydown', function(e) { + if (e.which == 13 && !search_completer.handleEnter()) { + e.preventDefault(); + search_on_submit(e); + return false; + } + return undefined; + }); + + var intro_task_elem = $('#intro-task'); + // TODO: messages (why tasks?) - this one is special but we want them to be handled in their own file. + if (!localStorage.intro_task_hide) { + intro_task_elem.show(); + } + $('#intro-task .task-close-button').click(function(e) { + localStorage.intro_task_hide = true; + intro_task_elem.hide(); + }); + + // TODO: interaction between the hack above and this + model_core.init(rz_config); + textanalysis.init(rz_core.graph); + } + + return { + main: main }; + } +); diff --git a/src/client/model/core.js b/src/client/model/core.js new file mode 100644 index 00000000..5c643ea1 --- /dev/null +++ b/src/client/model/core.js @@ -0,0 +1,187 @@ +"use strict" + +/** + * core model module - currently unused + */ +define(['util'], function(util) { + + /** + * return a random id + */ + var random_id; + + var random_id__hash = function() { + return Math.random().toString(36).substring(2, 10); + } + + var random_id__seq = function () { + var id = 0; + function get_next() { + var next = id; + id += 1; + return next; + } + return get_next; + } + + function random_node_name() { + return random_id__hash(); + } + + function init(config){ + if (config['rand_id_generator'] == 'hash') { + random_id = random_id__hash; + } + if (config['rand_id_generator'] == 'seq') { + random_id = random_id__seq(); + } + } + + function Node() { + } + Node.prototype.equals = function(other_node){ + return this.id == other_node.id; + } + + function Link() { + } + // adapte Link to force_layoutL create __src,__dst aliases + Link.prototype.__defineGetter__('source', function(){ + return this.__src; + }); + Link.prototype.__defineGetter__('target', function(){ + return this.__dst; + }); + + /** + * the most flexible way to create a node: - perform spec field validation - + * fill-in missing spec fields + */ + function create_node_from_spec(node_spec) { + var ret = new Node(); + + if (undefined != node_spec.id) { + // reuse id if present + __set_obj_id(ret, node_spec.id); + } + + util.assert(undefined != node_spec.name, 'create_node_from_spec: name missing'); + + ret.name = node_spec.name; + + // type + if (undefined == node_spec.type) { + console.debug('create_node_from_spec: undefined type, falling back to \'empty\''); + node_spec.type = 'empty'; + } + ret.type = node_spec.type; + + // status + ret.status = node_spec.status || 'unknown'; + + // visual + ret.x = node_spec.x; + ret.y = node_spec.y; + + // other + ret.state = node_spec.state; + ret.url = node_spec.url; + ret.start = node_spec.start; + ret.end = node_spec.end; + + return ret; + } + + function __set_obj_id(obj, id) { + Object.defineProperty(obj, "id", { + value: id, + enumerable: true, + writable: false + }); + } + + /** + * @param node_spec: id must not be defined + */ + function create_node__set_random_id(node_spec) { + if (undefined == node_spec) { + node_spec = {}; + } + + var ret = create_node_from_spec(node_spec); + + util.assert(undefined == ret.id); // id must not be defined in spec + __set_obj_id(ret, random_id()); + + return ret; + } + + function create_link__set_random_id(src, dst, link_spec) { + var ret = create_link_from_spec(src, dst, link_spec); + __set_obj_id(ret, random_id()); + return ret; + } + + /** + * determine if nodes are equal by name + * + * @param other_node + * @returns {Boolean} + */ + Node.prototype.equal_by_name = function(other) { + ret = this.name.toLowerCase() == other.name.toLowerCase(); + if (false == ret) { + console.debug(this.id + ' != ' + other.id); + } + return ret; + } + + function create_link_from_spec(src, dst, link_spec) { + var ret = new Link(); + + if (undefined != link_spec.id) { + // reuse id if present + __set_obj_id(ret, link_spec.id); + } + + util.assert(undefined != src, 'create_link_from_spec: src missing'); + util.assert(undefined != dst, 'create_link_from_spec: dst missing'); + util.assert(undefined != src.id, 'create_link_from_spec: src missing id'); + util.assert(undefined != dst.id, 'create_link_from_spec: dst missing id'); + util.assert(undefined != link_spec.name, 'create_link_from_spec: name missing, unable to deduce type'); + + ret.__src = src; + ret.__dst = dst; + ret.__type = link_spec.name; + + if (undefined == link_spec.name){ + console.warn('create_link_from_spec: name: ' + link_spec.name); + link_spec.name = ""; + } + ret.name = link_spec.name.trim(); + + ret.state = link_spec.state; + return ret; + } + + /** + * determine if links are equal by ID + * + * @param other_node + * @returns {Boolean} + */ + Link.prototype.equal_by_id = function(other) { + return this.id.toLowerCase() == other.id.toLowerCase(); + } + + return { + init : init, + Node: Node, // allow model adaptation + Link: Link, // allow model adaptation + random_node_name : random_node_name, + create_node_from_spec : create_node_from_spec, + create_node__set_random_id : create_node__set_random_id, + create_link_from_spec : create_link_from_spec, + create_link__set_random_id : create_link__set_random_id, + }; +}); diff --git a/src/client/model/diff.js b/src/client/model/diff.js new file mode 100644 index 00000000..8903b161 --- /dev/null +++ b/src/client/model/diff.js @@ -0,0 +1,187 @@ +"use strict" + +/** + * Diff module + */ +define([], + function() { + + /** + * A set of diff objects + */ + function Diff_Set(obj_spec) { + this.__diff_set_topo = []; + this.__diff_set_attr = []; + this.__diff_set_vis = []; + } + Diff_Set.prototype.add_diff_obj = function(diff_obj) { + if (diff_obj instanceof Topo_Diff) { + this.__diff_set_topo.push(diff_obj); + } + if (diff_obj instanceof Attr_Diff) { + this.__diff_set_attr.push(diff_obj); + } + if (diff_obj instanceof Vis_Diff) { + this.__diff_set_vis.push(diff_obj); + } + } + + /** + * Topological diff object + */ + function Topo_Diff(obj_spec) { + + this.link_set_rm = obj_spec.link_set_rm; + this.node_set_rm = obj_spec.node_set_rm; + this.node_set_add = obj_spec.node_set_add; + this.link_set_add = obj_spec.link_set_add; + + } + Topo_Diff.prototype.for_each_node_add = function(callback, this_arg) { + this.node_set_add.forEach(callback, this_arg); + } + + Topo_Diff.prototype.for_each_node_rm = function(callback, this_arg) { + this.node_set_rm.forEach(callback, this_arg); + } + + Topo_Diff.prototype.for_each_link_add = function(callback, this_arg) { + this.link_set_add.forEach(callback, this_arg); + } + + Topo_Diff.prototype.for_each_link_rm = function(callback, this_arg) { + this.link_set_rm.forEach(callback, this_arg); + } + + /** + * Attribute diff object, organized by type, where currently + * node,link types are supported + */ + function Attr_Diff(obj_spec) { + this.__type_node = {}; + this.__type_link = {}; + } + + Attr_Diff.prototype.init_attr_diff = function(type_name, id) { + + if ('node' != type_name && 'link' != type_name) { + console.error('attempt to init attribute diff for unsupported type: ' + type_name); + return; + } + + var type_field = '__type_' + type_name; + this[type_field][id] = { + '__attr_write' : {}, + '__attr_remove' : [] + }; + + return this; + } + + Attr_Diff.prototype.init_attr_diff_node = function(id) { + return this.init_attr_diff('node', id); + } + + Attr_Diff.prototype.init_attr_diff_link = function(id) { + return this.init_attr_diff('link', id); + } + + Attr_Diff.prototype.add_node_attr_write = function(n_id, attr_name, + attr_val) { + + if (undefined == this.__type_node[n_id]) { + this.init_attr_diff_node(n_id); + } + this.__type_node[n_id].__attr_write[attr_name] = attr_val; + return this; + } + + Attr_Diff.prototype.add_node_attr_rm = function(n_id, attr_name) { + if (undefined == this[n_id]) { + this.init_attr_diff(n_id); + } + this.__type_node[n_id].__attr_remove.push(attr_name); + return this; + } + + Attr_Diff.prototype.add_link_attr_write = function(l_id, attr_name, + attr_val) { + + if (undefined == this.__type_link[l_id]) { + this.init_attr_diff_link(l_id); + } + this.__type_link[l_id].__attr_write[attr_name] = attr_val; + return this; + } + + Attr_Diff.prototype.add_link_attr_rm = function(l_id, attr_name) { + if (undefined == this[l_id]) { + this.init_attr_diff(l_id); + } + this.__type_link[l_id].__attr_remove.push(attr_name); + return this; + } + + /** + * Visual diff object expressing any visual change to the state of a + * particular visualization type. + * + * @obj_spec if none is passed a default topo_diff is constructed + * with node,link add sets + */ + function Vis_Diff(obj_spec) { + } + + function new_topo_diff(obj_spec) { + /* + * validate obj_spec + */ + var ret; + if (undefined == obj_spec) { + obj_spec = { + node_set_add : [], + link_set_add : [], + } + ret = new Topo_Diff(obj_spec); + } else { + ret = new Topo_Diff(obj_spec); + } + return ret; + } + + function new_attr_diff(obj_spec) { + /* + * validate obj_spec + */ + // TODO + var ret = new Attr_Diff(obj_spec); + ret.__type_node = {}; // id-to-obj map + ret.__type_link = {}; // id-to-obj map + return ret; + } + + function new_vis_diff(obj_spec) { + /* + * validate obj_spec + */ + // TODO + var ret = new Vis_Diff(obj_spec); + return ret; + } + + function new_diff_set(obj_spec) { + /* + * validate obj_spec + */ + // TODO + var ret = new Diff_Set(obj_spec); + return ret; + } + + return { + new_topo_diff : new_topo_diff, + new_attr_diff : new_attr_diff, + new_vis_diff : new_vis_diff, + new_diff_set : new_diff_set, + } + });
\ No newline at end of file diff --git a/src/client/model/graph.js b/src/client/model/graph.js new file mode 100644 index 00000000..b3c8e33f --- /dev/null +++ b/src/client/model/graph.js @@ -0,0 +1,873 @@ +"use strict" + +define(['Bacon', 'consts', 'util', 'model/core', 'model/util', 'model/diff', 'rz_api_backend', 'rz_api_mesh', 'history', 'rz_bus', 'rz_config'], +function (Bacon, consts, util, model_core, model_util, model_diff, rz_api_backend, rz_api_mesh, history, rz_bus, rz_config) { + +var debug = false; + +function Graph() { + + var nodes = [], + id_to_node_map = {}, + links = [], + diffBus = new Bacon.Bus(); + + this.diffBus = diffBus; + + /** + * add node if no previous node is present whose id equals that of the node being added + * + * @return node if node was actually added + */ + this.addNode = function(spec) { + var node = this.__addNode(spec); + if (node) { + return node; + } + } + + /** + * Inner implementation + * + * @param notify whether or not a presenter notification will be sent, default = true + */ + function __addNode(spec, notify, peer_notify) { + var existing_node, + node; + + notify = undefined === notify ? true : notify; + peer_notify = undefined === peer_notify ? true : peer_notify; + + if (undefined == spec.id) { + existing_node = findNodeByName(spec.name) + if (existing_node){ + return existing_node; + } else { + node = model_core.create_node__set_random_id(spec); + if (debug) { + if ('bubble' != node.type){ + console.log('__addNode: stamping node id: ' + node.id + ', name: \'' + node.name + '\' (bubble)'); + }else { + console.log('__addNode: stamping node id: ' + node.id + ', name: \'' + node.name + '\''); + } + } + } + } else { + node = model_core.create_node_from_spec(spec); + } + + existing_node = find_node__by_id(node.id); + if (existing_node) { + console.log('__addNode: id collision: existing-node.id: \'' + existing_node.id + '\', ' + 'new-node.id: \'' + node.id + '\''); + return existing_node; + } + + util.assert(undefined != node.id, '__addNode: node id missing'); + nodes.push(node); + id_to_node_map[node.id] = node; + console.log('__addNode: node added: id: ' + node.id); + + if (rz_config.backend_enabled && peer_notify){ + var topo_diff = model_diff.new_topo_diff({ + node_set_add : [node].map(model_util.adapt_format_write_node), + }); + var on_success = function(){ + // FIXME: handle possible outcomes: + // - id merge: node already exists -> update id + // - link-merge: node already exists -> merge links, recurse? + }; + var on_error = function(){ + // TODO: add problem emblem to node + }; + rz_api_backend.commit_diff__topo(topo_diff, on_success, on_error); + } + + if (notify) { + diffBus.push({nodes: {add: [node]}}); + } + + return node; + } + this.__addNode = __addNode; + + this._remove_node_set = function(ns, peer_notify) { + + peer_notify = undefined === peer_notify ? true : peer_notify; + + var cascade_link_rm_set = []; // track cascading link removals + for (var j = 0; j < ns.length; j++) { + var n = ns[j]; + var i = 0; + while (i < links.length) { + var link = links[i]; + if ((link['__src'].equals(n)) || (link['__dst'].equals(n))) { // compare by id + links.splice(i, 1); + cascade_link_rm_set.push(link); + } + else { + i++; + } + } + var index = findNodeIndex(n.id, n.state); + if (index !== undefined) { + nodes.splice(index, 1); + + util.assert(undefined != n.id, '_remove_node_set: node id missing'); + delete id_to_node_map[n.id]; + } + } + + cascade_link_rm_set.forEach(function(n){ + console.log('_remove_node_set: removed node: id: ' + n.id); + }); + + if (rz_config.backend_enabled && peer_notify){ + var topo_diff = model_diff.new_topo_diff({ + node_set_rm : ns.map(function(n){ return n.id; }), + link_set_rm : cascade_link_rm_set.map(function(l){ return l.id; }), + }); + var on_success = function(){ + // FIXME: handle possible outcomes: + // - rm cascade of connected links + }; + var on_error = function(){ + // TODO: add problem emblem to node + }; + rz_api_backend.commit_diff__topo(topo_diff, on_success, on_error); + } + + if (ns.length > 0) { + diffBus.push({nodes: {removed: ns.map(function(n) { return n.id; })}}); + } + } + + this.removeNode = function(id) { + var n = find_node__by_id(id); + this._remove_node_set([n]); + } + + this.removeNodes = function(n_filer) { + var ns = find_node_set_by_filer(n_filer); + this._remove_node_set(ns); + } + + /** + * + * getConnectedNodesAndLinks + * + * @id + * @state - defines the starting node (must have id and state) + * @d - depth defining connected component. If -1 returns the entire connected component. (can be the whole graph) + * + * NOTE: chainlinks are treated specially, they don't count for distance. So all their decendants will be added. + * + * NOTE: temp state nodes (n.state === 'temp') are ignored. + * + * @return - { + * 'node': [node] + * 'link': [link] + * } + * + * TODO: rewrite using efficient data structure. Right now iterates over everything + * TODO: implement for d !== 1 + * + */ + this.getConnectedNodesAndLinks = function(chosen_nodes, d) { + var ret = {'nodes':[], 'links':[]}; + + function addNode(node) { + if (chosen_nodes.filter(function (n) { return n.id == node.id; }).length == 1) { + return; + } + ret.nodes.push(node); + } + function same(n1, n2) { + // XXX: using name comparison because one of the nodes might be stale + return compareNames(n1.name, n2.name); + } + + if (chosen_nodes === undefined) { + console.log('getConnectedNodesAndLinks: bug: called with undefined node'); + return; + } + if (d !== 1) { + console.log('getConnectedNodesAndLinks: bug: not implemented for d == ' + d); + } + d = d || 1; + + if (chosen_nodes.length === undefined) { + console.log('getConnectedNodesAndLinks: expected array'); + } + + links.forEach(function(link) { + chosen_nodes.forEach(function (n) { + var adjacentnode; + if (same(link.__src, n)) { + adjacentnode = find_node__by_id(link.__dst.id); + if (adjacentnode.state !== "temp") { + addNode({type: 'exit', node: adjacentnode}); + } + ret.links.push({type: 'exit', link: link}); + if (link.__dst.type === "chainlink") { + links.forEach(function(link2) { + if (link.__dst.id === link2.__dst.id && + link2.__dst.type === "chainlink" && + link2.__dst.state !== "temp") { + adjacentnode = find_node__by_id(link2.__src.id); + if (adjacentnode.state !== "temp") { + addNode({type: 'enter', node: adjacentnode}); + } + ret.links.push({type: 'enter', link: link2}); + } + }); + } + } + if (same(link.__dst, n)) { + adjacentnode = find_node__by_id(link.__src.id); + if (adjacentnode.state !== "temp") { + addNode({type: 'enter', node: adjacentnode}); + } + ret.links.push({type: 'enter', link: link}); + } + }); + }); + return ret; + } + + /* compareSubset: + * state: one of the optional states that defines a subgraph + * new_nodes: array of objects with name + * new_links: array of length two arrays [source_name, target_name] + * returns: true if current and new graph are homomorphic up to + * a single node id change. false otherwise + */ + this.compareSubset = function(state, new_nodes, new_links) { + // Note: the nodes include a state=='temp', type=='bubble' node + // but it's ok since it exists both in new_nodes and in state_nodes + var state_nodes = findNodes(null, state).filter(function (nd) { + return nd.type !== 'bubble'; + }); + var state_links = findLinks(state).map(function(link) { + return [link.__src.name, link.__dst.name]; + }).sort(); + var k; + var state_source, state_target, new_source, new_target; + var changed_nodes; + var verbose = false; // XXX should be global. + var set_old_name, set_new_name; + + new_nodes.map(function (f) { + if (!f.name) { + console.log('missing name on node. node follows'); + console.log(f); + } + }); + new_nodes.sort(); + new_links.sort(); + if (new_nodes.length != state_nodes.length || new_links.length != state_links.length) { + if (verbose) { + console.log('not same size: new/old ' + new_nodes.length + ' / ' + state_nodes.length + '; ' + + new_links.length + ' / ' + state_links.length); + } + return {graph_same: false}; + } + changed_nodes = util.set_diff(util.set_from_array(state_nodes.map(function(d) { return d.name; })), + util.set_from_array(new_nodes.map(function (f) { return f.name; }))); + // we allow any number of changed nodes as long as we it is 1 or 2 :) + if (changed_nodes.a_b.length > 2) { + if (verbose) { + console.log('changed too many nodes'); + console.log(changed_nodes); + } + return {graph_same: false}; + } + set_old_name = util.set_from_array(changed_nodes.a_b); + set_new_name = util.set_from_array(changed_nodes.b_a); + for (k = 0 ; k < state_links.length ; ++k) { + state_source = state_links[k][0]; + state_target = state_links[k][1]; + new_source = new_links[k][0]; + new_target = new_links[k][1]; + if ((state_source !== new_source && + !(state_source in set_old_name && new_source in set_new_name)) + || + (state_target !== new_target && + !(state_target in set_old_name && new_target in set_new_name))) { + if (verbose) { + console.log('not same link: ' + + state_source + '->' + state_target + ' != ' + + new_source + '->' + new_target); + console.log('state_source === new_source: ' + String(state_source === new_source)); + console.log('state_target === new_target: ' + String(state_target === new_target)); + console.log(set_old_name); + console.log(set_new_name); + } + return {graph_same: false}; + } + } + return {graph_same: true, old_name: changed_nodes.a_b, new_name: changed_nodes.b_a}; + } + + this.addLinkByName = function(src_name, dst_name, name, state, drop_conjugator_links) { + + var src = findNodeByName(src_name), + dst = findNodeByName(dst_name), + src_id = src ? src.id : null, + dst_id = dst ? dst.id : null; + + if (src_id === null || dst_id === null) { + console.log('error: link of missing nodes: ' + src_name + ' (' + src_id + ') -> ' + + dst_name + ' (' + dst_id + ')'); + return; + } + + var link = model_core.create_link__set_random_id(src, dst, { name: name, + state: state }); + this.addLink(link); + } + + function addLink(link, peer_notify) { + + util.assert(link instanceof model_core.Link); + + peer_notify = undefined === peer_notify ? true : peer_notify; + + var existing_link = findLink(link.__src.id, link.__dst.id, link.name); + + if (undefined == existing_link) { + + links.push(link); + + if (rz_config.backend_enabled && peer_notify){ + var topo_diff = model_diff.new_topo_diff({ + link_set_add : [link].map(model_util.adapt_format_write_link), + }); + var on_success = function(){ + // FIXME: handle possible outcomes: + // - id merge: link already exists -> update id + // - attr-merge: link already exists -> merge attrs + }; + var on_error = function(){ + // TODO: add problem emblem to node + }; + rz_api_backend.commit_diff__topo(topo_diff, on_success, on_error); + } + + diffBus.push({links: {add: [link]}}); + } else { + existing_link.name = link.name; + existing_link.state = link.state; + } + } + this.addLink = addLink; + + this.editLink = function(src_id, dst_id, newname, newstate) { + var link = findLink(src_id, dst_id, newname); + + if (link === undefined) { + return; + } + link.name = newname; + if (newstate !== undefined) { + link.state = newstate; + } + rz_bus.names.push([newname]); + } + + this.editLinkTarget = function(src_id, dst_id, new_dst_id) { + var link = findLink(src_id, dst_id, null); + if (link !== undefined) { + link.__dst = find_node__by_id(new_dst_id); + + } else { + + } + } + + this.update_node = function(node, new_node_spec, on_success, on_error) { + util.assert(node instanceof model_core.Node); + + if (rz_config.backend_enabled){ + + if (node.name != new_node_spec.name){ + /* + * handle name update collision: suggest removal first + */ + var n_eq_name = findNodeByName(new_node_spec.name); + if (undefined != n_eq_name) { + // delete colliding node on rename + console.warn('update_node: name collision blocked due to node rename'); + undefined != on_error && on_error(); + return; + } + + node['name'] = new_node_spec['name']; // [!] may still fail due to server NAK + } + + var attr_diff = model_diff.new_attr_diff(); + for (var key in new_node_spec){ + attr_diff.add_node_attr_write(node.id, key, new_node_spec[key]); + } + + var on_ajax_success = function(id_to_node_map){ + var node_id = node.id; // original node id + if (id_to_node_map[node_id].id != node_id){ + // TODO: handle incoming ID update + util.assert(false, 'update_node: id attr change'); + } + + var ret_node = id_to_node_map[node_id]; + for (var key in ret_node){ + if ('name' == key || 'id' == key){ + continue; + } + node[key] = ret_node[key]; + } + + // TODO: handle NAK: add problem emblem to node + on_success(); + }; + + var on_ajax_error = function(){ + }; + + rz_api_backend.commit_diff__attr(attr_diff, on_ajax_success, on_ajax_error); + } + } + + this.editNameByName = function(old_name, new_name) { + var node = findNodeByName(old_name); + + if (node === undefined) { + console.log('editNameByName: error: cannot find node with name ' + old_name); + return; + } + return this.editName(node.id, new_name); // TODO: introduce Node class (yes Amir, I'm now down with that). + } + + this.editName = function(id, new_name) { + var n_eq_name = findNodeByName(new_name); + var n_eq_id = find_node__by_id(id); + var acceptReplace=true; + + if (n_eq_id === undefined) { + return; + } + if (n_eq_id.name == new_name) { + return; + } + if (n_eq_name !== undefined && n_eq_id.state !== 'temp' && !compareNames(n_eq_id.name, new_name)) { + acceptReplace = confirm('"' + n_eq_name.name + '" will replace "' + n_eq_id.name + '", are you sure?'); + if (acceptReplace){ + for (var i = 0; i < links.length; i++) { + if (links[i].__src === n_eq_id) { + links[i].__src = n_eq_name; + } + if (links[i].__dst === n_eq_id) { + links[i].__dst = n_eq_name; + } + } + this.removeNode(n_eq_id.id); + } + } else { + n_eq_id.name = new_name; + } + } + + this.editDates = function(id, state, start, end) { + var n = find_node__by_id(id); + if (state != n.state){ + return; + } + if ((n !== undefined)) { + n.start = start; + n.end = end; + } + } + + /** + * editType: + * + * @return true if type changed + */ + this.editType = function(id, state, newtype) { + return this._editProperty(id, state, 'type', newtype); + } + + this.editURL = function(id, state, url) { + return this._editProperty(id, state, 'url', url); + } + + this._editProperty = function(id, state, prop, value) { + var n = find_node__by_id(id); + if (state != n.state){ + return false; + } + + if ((n === undefined)) { + return false; + } + n[prop] = value; + return true; + } + + this.editStatus = function(id, state, status) { + return this._editProperty(id, state, 'status', status); + } + + this.editState = function(id, state, newstate) { + return this._editProperty(id, state, 'state', newstate); + } + + this.findCoordinates = function(id) { + var n = find_node__by_id(id); + if ((index !== undefined)) { + $('.typeselection').css('top', n.y - 90); + $('.typeselection').css('left', n.x - 230); + } + } + + this.removeLink = function(link) { + var i; + + for (i = 0 ; i < links.length; ++i) { + if (link.id !== undefined) { + if (link.id === links[i].id) { + links.splice(i, 1); + return; + } + } else { + if (link.__src.id === links[i].__src.id && link.__dst.id === links[i].__dst.id) { + links.splice(i, 1); + return; + } + } + } + console.log('bug: attempt to remove non existant link'); + } + + this.removeLinks = function(state) { + var id = null; + var ls = findLinks(state); + for (var j = 0; j < ls.length; j++) { + var l = ls[j]; + var i = 0; + while (i < links.length) { + if (links[i] === l) links.splice(i, 1); + else i++; + } + } + } + + var findLink = function(src_id, dst_id, name) { + for (var i = 0; i < links.length; i++) { + if (links[i].__src.id === src_id && links[i].__dst.id === dst_id) { + return links[i]; + } + } + } + + var findLinks = function(state) { + var foundLinks = []; + for (var i = 0; i < links.length; i++) { + if (links[i].state == state) { + foundLinks.push(links[i]); + } + } + return foundLinks; + } + + var compareNames = function(name1, name2) { + return name1.toLowerCase() === name2.toLowerCase(); + }; + + var hasNodeByName = function(name, state) { + return nodes.filter(function (n) { + return compareNames(n.name, name) && n.state === state; + }).length > 0; + } + this.hasNodeByName = hasNodeByName; + + var hasNodeByNameAndNotState = function(name, state) { + return nodes.filter(function(n) { + return compareNames(n.name, name) && n.state !== state; + }).length > 0; + } + this.hasNodeByNameAndNotState = hasNodeByNameAndNotState; + + var hasNode = function(id, state) { + var i; + + for (i = 0 ; i < nodes.length; ++i) { + if (nodes[i].id === id && nodes[i].state === state) { + return true; + } + } + return false; + } + this.hasNode = hasNode; + + /** + * return node whose id matches the given id or undefined if no node was found + */ + var find_node__by_id = function(id) { + return id_to_node_map[id]; + } + + /** + * @param filer: must return true in order for node to be included in the returned set + */ + var find_node_set_by_filer = function(filter) { + var ret = []; + nodes.map(function(n){ + if (true == filter(n)){ + ret.push(n); + } + }); + return ret; + } + + var findNodeByName = function(name) { + for (var i = 0 ; i < nodes.length ; ++i) { + if (compareNames(nodes[i].name, name)) { + return nodes[i]; + } + } + } + + var findNodes = function(id, state) { + // id=id.toLowerCase(); + var foundNodes = []; + for (var i = 0; i < nodes.length; i++) { + if ((id && nodes[i].id === id) || (state && nodes[i].state === state)) + foundNodes.push(nodes[i]); + } + return foundNodes; + } + + var findNodeIndex = function(id, state) { + for (var i = 0; i < nodes.length; i++) { + if ((id && nodes[i].id === id) || (state && nodes[i].state === state)) + return i; + }; + } + + function clear() { + nodes.length = 0; + links.length = 0; + } + this.clear = clear; + + function empty() { + return nodes.length == 0 && links.length == 0; + } + this.empty = empty; + + // @ajax-trans + this.commit_diff_set = function (diff_set) { + + function on_success(data){ + console.log('commit_diff_set:on_success: TODO impl'); + } + + rz_api_mesh.broadcast_possible_next_diff_block(diff_set); + } + + /** + * perform initial DB load from backend + * + * @param on_success: should be used by MVP presentors to trigger UI update + */ + // @ajax-trans + function load_from_backend(on_success) { + + function on_success__ajax(data){ + var n_set = []; // added node set + var l_set = []; // added link set + var len; + + data['node_set'].map(function(n_spec) { + n_spec = model_util.adapt_format_read_node(n_spec); + + util.assert(undefined != n_spec.id, 'load_from_backend: n_spec missing id'); + + var n = __addNode(n_spec, false, false); + n_set.push(n); + }); + + data['link_set'].map(function(l_spec){ + var l_ptr = model_util.adapt_format_read_link_ptr(l_spec); + + util.assert(undefined != l_ptr.id, 'load_from_backend: l_ptr missing id'); + + // resolve link ptr + var src = find_node__by_id(l_ptr.__src_id), + dst = find_node__by_id(l_ptr.__dst_id); + + // cleanup & reuse as link_spec + delete l_ptr.__src_id; + delete l_ptr.__dst_id; + var link_spec = l_ptr; + var link = model_core.create_link_from_spec(src, dst, link_spec); + var l = addLink(link, false); + l_set.push(l); + }); + + undefined != on_success && on_success() + } + + rz_api_backend.clone(0, on_success__ajax); + } + this.load_from_backend = load_from_backend; + + this.load_from_json = function(json) { + var data = JSON.parse(json), + added_names, + that = this; + + clear(); + if (data == null) { + console.log('load callback: no data to load'); + return; + } + added_names = data.nodes.map(function(node) { + return that.__addNode({id:node.id, name:node.name ? node.name : node.id, + type:node.type,state:"perm", + start:new Date(node.start), + end:new Date(node.end), + status:node.status, + url:node.url, + x: node.x, + y: node.y, + }, false, false).name; + }); + data.links.forEach(function(link) { + that.addLink(link.__src, link.__dst, link.name, "perm"); + }); + this.clear_history(); + rz_bus.names.push(added_names); + } + + this.save_to_json = function() { + var d = {"nodes":[], "links":[]}; + for(var i = 0 ; i < nodes.length ; i++){ + var node = nodes[i]; + d['nodes'].push({ + "id": node.id, + "name": node.name, + "type":node.type, + "state":"perm", + "start":node.start, + "end":node.end, + "status": node.status, + "url": node.url, + "x": node.x, + "y": node.y, + }); + } + for(var j=0 ; j < links.length ; j++){ + var link = links[j]; + d['links'].push({ + "__src":link.__src.id, + "__dst":link.__dst.id, + "name":link.name + }); + } + return JSON.stringify(d); + } + + this.set_user = function(user) { + var elem = $('svg g.zoom')[0]; + this.user = user; + this.history = new history.History(this.user, this, elem); + } + + function clear_history() { + if (this.history !== undefined) { + this.history.clear(); + } + } + + this.clear_history = clear_history; + + var get_nodes = function() { + return nodes; + }; + this.nodes = get_nodes; + + var get_links = function() { return links; }; + this.links = get_links; + + function setRegularState() { + var x, node, link, s; + + for (x in nodes) { + node = nodes[x]; + s = node.state; + if (s === 'chosen' || s === 'enter' || s === 'exit') { + node.state = 'perm'; + } + } + for (x in links) { + link = links[x]; + s = link.state; + if (s === 'chosen' || s === 'enter' || s === 'exit') { + link.state = 'perm'; + } + } + } + this.setRegularState = setRegularState; + + this.findByVisitors = function(node_visitor, link_visitor) { + var n_length = nodes.length, + l_length = links.length, + selected = [], + i, + node, + link, + state; + + if (!node_visitor) { + return; + } + + for (i = 0 ; i < n_length; ++i) { + node = nodes[i]; + if (node.state == 'temp') { + continue; + } + if (node_visitor(node)) { + selected.push(node); + } + } + return selected; + } + + function markRelated(names) { + removeRelated(); + nodes.forEach(function (node) { + names.forEach(function (name) { + if (compareNames(node.name, name) && node.state != 'temp') { + node.state = 'related'; + } + }); + }); + } + this.markRelated = markRelated; + + function removeRelated() { + nodes.forEach(function (node) { + if (node.state == 'related') { + node.state = 'perm'; + } + }); + } + this.removeRelated = removeRelated; + +} + +return { + Graph: Graph, +}; + +}); diff --git a/src/client/model/util.js b/src/client/model/util.js new file mode 100644 index 00000000..d938fbc9 --- /dev/null +++ b/src/client/model/util.js @@ -0,0 +1,134 @@ +"use strict" + +/** + * model utility functions: - convert from/to client/backend data + * representations + */ +define([ 'jquery', 'model/diff' ], function($, model_diff) { + + function __sanitize_label__write(label_str){ + var ret = label_str[0].toUpperCase() + + label_str.substring(1).toLowerCase(); + return ret; + } + + function __sanitize_label__read(label_str){ + return label_str.toLowerCase(); + } + + /** + * read by adapting from backend format + */ + function adapt_format_read_node(n_raw) { + var ret; + + ret = $.extend({ + // type: + // - discard all but first label + // - adjust to lowercase + 'type' : __sanitize_label__read(n_raw['__label_set'][0]), + 'state' : 'perm', + }, n_raw); + + delete ret.__label_set; + + return ret; + } + + /** + * write by adapting to backend format + */ + function adapt_format_write_node(n_raw) { + var ret = $.extend({ + }, n_raw); + + ret['__label_set'] = [__sanitize_label__write(n_raw.type)]; + + delete ret.state; + delete ret.status + delete ret.type; + + return ret + } + + /** + * read by adapting from backend format + */ + function adapt_format_read_link_ptr(l_raw) { + var ret; + + ret = $.extend({ + '__src_id' : l_raw['__src_id'], + '__dst_id' : l_raw['__dst_id'], + // type: + // - discard all but first label + // - adjust to lowercase + '__type' : __sanitize_label__read(l_raw['__label_set'][0]), + 'state' : 'perm', + }, l_raw); + + ret['name'] = ret['__type']; + + delete ret.__label_set; + + return ret; + } + + /** + * write by adapting to backend format + */ + function adapt_format_write_link(l_raw) { + var ret = $.extend({ + '__src_id' : l_raw.source.id, + '__dst_id' : l_raw.target.id, + }, l_raw); + + ret['__label_set'] = [__sanitize_label__write(l_raw.__type)]; + + delete ret.__dst; + delete ret.__src; + delete ret.source; // introduced by d3 accessor methods + delete ret.state; + delete ret.status; + delete ret.target; + + return ret; + } + + /** + * write adapt diff from node set, link set. sets may be passed by reference + * as they are cloned + */ + function adapt_format_write_topo_diff(n_set, l_set) { + + var new_n_set = $.extend([], n_set); + var new_l_set = $.extend([], l_set); + + // filter out 'bubble' nodes + new_n_set = new_n_set.filter(function(n) { + return 'bubble' != n.type; + }); + + new_n_set = $.map(new_n_set, function(n, _) { + return adapt_format_write_node(n); + }) + + new_l_set = $.map(new_l_set, function(l, _) { + return adapt_format_write_link(l); + }) + + var topo_diff = new model_diff.new_topo_diff({ + node_set_add : new_n_set, + link_set_add : new_l_set + }); + return topo_diff; + } + + return { + adapt_format_read_node : adapt_format_read_node, + adapt_format_read_link_ptr : adapt_format_read_link_ptr, + adapt_format_write_node : adapt_format_write_node, + adapt_format_write_link : adapt_format_write_link, + adapt_format_write_topo_diff : adapt_format_write_topo_diff, + } +});
\ No newline at end of file diff --git a/src/client/robot.js b/src/client/robot.js new file mode 100644 index 00000000..700e41b6 --- /dev/null +++ b/src/client/robot.js @@ -0,0 +1,70 @@ +define(['jquery'], function($) { + +var sentence=""; +/*sentence+=" #Rhizibot is showing you a #tutorial|"; +sentence+="#Rhizi visualizes data with #Graphs and #relationships|"; +sentence+="#links and #nodes have #context and #meaning|"; + +sentence+="#entities and #concepts are #nodes|"; +sentence+="We built this entire graph in 30 seconds, try it out yourself!";*/ + +sentence+='##Rhizi is a tool for creating interactive #Networks|'; +sentence+='Create #Networks by writing #Sentences|'; +sentence+='like a #Tweet you can put a #Hashtag in your #Sentences|'; +sentence+='#Hello|'; +sentence+='#John|'; +sentence+='Put your text between #Commas to use #"Multiple words"|'; +sentence+='#"John Smith"|'; +sentence+='#"Beauty and the beast"|'; +sentence+='Use the word #And to #"Connect multiple things together"|'; +sentence+='#John likes #Apples and #Oranges and #Pistachio|'; +//sentence+='Choose the #"node type" by using the #"TAB key"|'; +sentence+='#Click on any #Node to change and modify it|'; +sentence+='#Play around and have #Fun!|'; + +var robot = function (element, sentence) { + var r = { + speed: 1, + counter: 0, + sentence: sentence, + sentencecounter: 0, + element: element || $('#textanalyser')}; + r.next_event = function () { + if(r.counter <= r.sentence.length) { + var text = r.element.val(); + r.counter++; + if (r.sentence.charAt(r.counter) !== "|") { + r.element.val(text + r.sentence.charAt(r.counter)); + if ('oninput' in document.documentElement) { + r.element.trigger('input', {}); + } + //if(Math.random()>0.9)graph.editType("x","temp",nodetypes[Math.round(Math.random()*4)]); + if (r.sentence.charAt(r.counter)==="#") { + r.sentencecounter++; + r.timeout_id = window.setTimeout( r.next_event, 30/r.speed+Math.random()*160/r.speed ); + } else { + r.timeout_id = window.setTimeout( r.next_event, 50/r.speed+Math.round(Math.random()*100/r.speed) ); + } + } else { + var e = jQuery.Event("keypress"); + e.which = 13; + e.keyCode = 13; + $("#textanalyser").trigger(e); + window.setTimeout( r.next_event, 650/r.speed ); + } + } else { + window.clearInterval(r.timeout_id); + } + } + return r; +} + +$('.logo').click(function(){ + setTimeout( robot(undefined, sentence).next_event, 1000 ); +}); + +/*var answer = confirm ("Would you like a tutorial?") +if (answer) +setTimeout( Robot, 100 );*/ + +}); // define diff --git a/src/client/rz_api_backend.js b/src/client/rz_api_backend.js new file mode 100644 index 00000000..da073f9f --- /dev/null +++ b/src/client/rz_api_backend.js @@ -0,0 +1,222 @@ +"use strict"; + +/** + * API calls designed to execute against a local backend service + */ +define(['rz_config'], function(rz_config) { + + function RZ_API_Backend() { + + var rz_server_url = 'http://' + rz_config.rz_server_host + ':' + rz_config.rz_server_port; + + /** + * issue rhizi server ajax call + */ + var ajax_rs = function(path, req_opts, on_success, on_error) { + + function on_error_wrapper(xhr, err_text, err_thrown) { + // log wrap callback + console.error('error: \'' + err_text + '\''); + if (on_error && typeof (on_error) === "function") { + on_error(err_type, err_text); + } + } + + function on_success_wrapper(xhr, text) { + // log wrap callback + var ret_data = xhr.data; + console.log('success: ' + JSON.stringify(ret_data)); + + if (on_success) { + on_success(ret_data); + } + } + + /* + * add common request options + */ + req_opts.dataType = "json"; + req_opts.contentType = "application/json; charset=utf-8"; + req_opts.error = on_error_wrapper; + req_opts.success = on_success_wrapper; + req_opts.headers = {}; + req_opts.timeout = 8000; // ms + req_opts.crossDomain = true; + + $.ajax(rz_server_url + path, req_opts); + } + + /** + * common attr_diff + */ + this.commit_diff__attr = function(attr_diff, on_success, on_error) { + + var post_dict = { + 'attr_diff' : attr_diff + } + + var req_opts = { + type : 'POST', + data : JSON.stringify(post_dict), + }; + + return ajax_rs('/graph/diff-commit-attr', req_opts, on_success, + on_error); + } + + /** + * commit topo_diff + */ + this.commit_diff__topo = function(topo_diff, on_success, on_error) { + + var post_dict = { + 'topo_diff' : topo_diff + } + + var req_opts = { + type : 'POST', + data : JSON.stringify(post_dict), + }; + + return ajax_rs('/graph/diff-commit-topo', req_opts, on_success, + on_error); + } + + /** + * commit vis_diff + */ + this.commit_diff__vis = function(vis_diff, on_success, on_error) { + // TODO impl + } + + /** + * commit a diff_set + */ + this.commit_diff__set = function(diff_set, on_success, on_error) { + + var post_dict = { + 'diff_set' : diff_set + } + + var req_opts = { + type : 'POST', + data : JSON.stringify(post_dict), + }; + + return ajax_rs('/graph/diff-commit-set', req_opts, on_success, + on_error); + } + + /** + * clone rhizi repo + */ + this.clone = function(depth, on_success, on_error) { + + // prep request + var req_opts = { + type : 'POST', + }; + + ajax_rs('/graph/clone', req_opts, on_success, on_error); + } + + /** + * load node-set by id attribute + * + * @param on_complete_cb + * will be called with the returned json data on successful + * invocation + * @param on_error + * error callback + */ + this.load_node_set = function(id_set, on_success, on_error) { + + // prep request data + var post_dict = { + 'id_set' : id_set + } + + // prep request + var req_opts = { + type : 'POST', + data : JSON.stringify(post_dict), + }; + + return ajax_rs('/load/node-set-by-id', req_opts, on_success, + on_error); + } + + /** + * load link set by src / dst id + */ + this.load_link_set = function(link_ptr_set, on_success, on_error) { + + // prep request data + var post_dict = { + 'link_ptr_set' : link_ptr_set + } + + // prep request + var req_opts = { + type : 'POST', + data : JSON.stringify(post_dict), + }; + + return ajax_rs('/load/link-set/by_link_ptr_set', req_opts, + on_success, on_error); + } + + /** + * add a node set + */ + this.add_node_set = function(n_set, on_success, on_error) { + var topo_diff = new Topo_Diff(); + topo_diff.node_set_add = n_set; + + return this.topo_diff_commit(topo_diff, on_success, on_error); + } + + /** + * add a link set + */ + this.add_link_set = function(l_set, on_success, on_error) { + var topo_diff = new Topo_Diff(); + topo_diff.link_set_add = l_set; + + return this.topo_diff_commit(topo_diff); + } + + /** + * remove node set + */ + this.remove_node_set = function() { + var topo_diff = null; + return this.topo_diff_commit(topo_diff); + } + + /** + * remove link set + */ + this.remove_link_set = function() { + var topo_diff = null; + return this.topo_diff_commit(topo_diff); + } + + /** + * update node set + */ + this.update_node_set = function(attr_diff, on_success, on_error) { + return this.attr_diff_commit(attr_diff, on_success, on_error); + } + + /** + * update link set + */ + this.update_link_set = function() { + var attr_diff = null; + return this.attr_diff_commit(null); + } + } + + return new RZ_API_Backend(); +}); diff --git a/src/client/rz_api_mesh.js b/src/client/rz_api_mesh.js new file mode 100644 index 00000000..9014dbac --- /dev/null +++ b/src/client/rz_api_mesh.js @@ -0,0 +1,22 @@ +/** + * API calls designed to execute in decentralized fashion + */ +define(['rz_api_backend'], +function(rz_api_backend) { + function RZ_API_Mesh() { + + /** + * suggest diff block and await commit/reject consensus + */ + // @ajax-trans + this.broadcast_possible_next_diff_block = function (diff_set) { + + function on_success(data){ + // TODO impl + } + + rz_api_backend.commit_diff_set(diff_set); + } + } + return new RZ_API_Mesh(); +}); diff --git a/src/client/rz_bus.js b/src/client/rz_bus.js new file mode 100644 index 00000000..38033bc4 --- /dev/null +++ b/src/client/rz_bus.js @@ -0,0 +1,15 @@ +"use strict" + +define(['consts', 'Bacon'], +function(consts, Bacon) +{ + var ui_key_bus = new Bacon.Bus(), + ui_input_bus = new Bacon.Bus(), + names_bus = new Bacon.Bus(); + + return { + ui_key: ui_key_bus, + ui_input: ui_input_bus, + names: names_bus, + }; +}); diff --git a/src/client/rz_config.js b/src/client/rz_config.js new file mode 100644 index 00000000..e55e74d0 --- /dev/null +++ b/src/client/rz_config.js @@ -0,0 +1,9 @@ +define(function() { + + return { + 'rand_id_generator' : 'hash', + 'rz_server_host': 'rhizi.local', + 'rz_server_port': '8080', + 'backend_enabled': false, + }; +}); diff --git a/src/client/rz_core.js b/src/client/rz_core.js new file mode 100644 index 00000000..7ec19692 --- /dev/null +++ b/src/client/rz_core.js @@ -0,0 +1,756 @@ +"use strict" + +define(['jquery', 'd3', 'consts', 'rz_bus', 'util', 'model/graph', 'model/core', 'view/helpers', 'view/view', 'rz_observer', 'view/selection', 'rz_config'], +function($, d3, consts, rz_bus, util, model_graph, model_core, view_helpers, view, rz_observer, selection, rz_config) { + +var addednodes = [], + vis, + graphinterval = 0, + timeline_timer = 0, + deliverables = [], + circle, // <-- should not be module globals. + scrollValue = 0, + graph, + drag, + force; + +// "CSS" for SVG elements. Reused for editing elements. +var node_text_dx = 15, + node_text_dy = '.30em', + svg_input_fo_node_x = node_text_dx, + svg_input_fo_node_y = '-.70em', + svg_input_fo_height = '30px'; + +/** + * svgInput - creates an embedded input element under a given + * + * edit_node(@sibling, @node) + * edit_link(@sibling, @link) + */ +var svgInput = (function() { + var measure_node = $('#measure-node')[0], + measure_link = $('#measure-link')[0], + original_element, + is_link; + + function appendForeignElementInputWithID(base, elemid, width, height) + { + var input = document.createElement('input'), + body = document.createElement('body'), + fo = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject'); + + body.appendChild(input); + + fo.setAttribute('height', height || svg_input_fo_height); + fo.style.pointerEvents = 'none'; + input.style.pointerEvents = 'all'; + fo.appendChild(body); + base.appendChild(fo); + input.setAttribute('id', elemid); + return input; + } + + function measure(text) + { + var span; + + span = is_link ? measure_link : measure_node; + span.innerHTML = text; + return span.getBoundingClientRect().width; // $().width() works too + } + + function onkeydown(e) { + var ret = undefined, + jelement = createOrGetSvgInput(), + element = jelement[0], + newname = jelement.val(), + fo = createOrGetSvgInputFO(), + d; + + if (element != this) { + console.log('unexpected editname_on_keypress this should be the svg-input element'); + } + + if (e.which == 13 || e.which == 27) { + ret = false; + d = jelement.data().d; + if (e.which == 13 && newname != d.name) { + if (d.hasOwnProperty('__src')) { + graph.editLink(d.__src.id, d.__dst.id, newname); + } else { + graph.editName(d.id, newname); + } + rz_bus.names.push([newname]); + update_view__graph(true); + } + hide(); + } + rz_bus.ui_key.push({where: consts.KEYSTROKE_WHERE_EDIT_NODE, keys: [e.which]}); + return ret; + }; + + function resize_measure(e) { + resize(measure($(e.target).val()) + 30); + } + + function resize(new_width) { + var svg_input = createOrGetSvgInput(), + fo = createOrGetSvgInputFO(); + + svg_input.css('width', new_width); + fo.attr('width', new_width); + } + + // FIXME: element being deleted. Some delete is legit - removal of related element. Some isn't (a click). + // Instead of investigating (time constraint) reparenting as sibling, and introducing + // this function. Cost of creation of element is negligble, it's just ugly.. + function createOrGetSvgInput() + { + var svg_input_name = 'svg-input', + svg_input_selector = '#' + svg_input_name, + svg_input = $(svg_input_selector); + + if (svg_input.length == 0) { + console.log('creating new svg-input'); + svg_input = $(appendForeignElementInputWithID(vis[0][0], svg_input_name)); + svg_input.on('keydown', onkeydown); + svg_input.bind('change keypress', resize_measure); + } + return svg_input; + } + + function createOrGetSvgInputFO() + { + return createOrGetSvgInput().parent().parent(); + } + + /* + * @param e visual node element + * @param n node model object + */ + function enable(e, n) { + var oldname = n.name, + svg_input = createOrGetSvgInput(), + fo = createOrGetSvgInputFO(); + + is_link = n.hasOwnProperty('__src'); + + e.parentNode.appendChild(fo[0]); // This will unparent from the old parent + if (is_link) { + fo.attr('transform', e.getAttribute('transform')); + // XXX links set the text-anchor middle attribute. no idea how to do that + fo.attr('x', -$(e).width() / 2); + fo.attr('y', -$(e).height() / 2 - 3); // XXX This minus 3 is only kinda ok. + fo.attr('class', 'svg-input-fo-link'); + } else { + fo.attr('x', svg_input_fo_node_x); + fo.attr('y', svg_input_fo_node_y); + fo.attr('transform', null); + fo.attr('class', 'svg-input-fo-node'); + } + // Set width correctly + resize(measure(oldname) + 30); + fo.show(); + svg_input.val(oldname); + svg_input.data().d = n; + svg_input.focus(); + if (original_element) { + original_element.show(); + } + original_element = $(e); + original_element.hide(); + // TODO: set cursor to correct location in text + } + + function hide() { + createOrGetSvgInputFO().hide(); + if (original_element && original_element.show) { + original_element.show(); + } + } + + return { + enable: enable, + hide: hide, + }; +}()); + + + +function recenterZoom() { + vis.attr("transform", "translate(0,0)scale(1)"); +} + +// zoom or drag +var zoomInProgress = false; + +var initDrawingArea = function () { + + function zoom() { + zoomInProgress = true; + vis.attr("transform", "translate(" + d3.event.translate + ")scale(" + d3.event.scale + ")"); + d3.event.sourceEvent.stopPropagation(); + } + + function dragstarted(d) { + d3.event.sourceEvent.stopPropagation(); + d3.select(this).classed("dragging", true); + d.dragstart = {clientX:d3.event.sourceEvent.clientX, clientY:d3.event.sourceEvent.clientY}; + force.stop(); + } + + function dragged(d) { + d.x = d3.event.x; + d.y = d3.event.y; + tick(); + } + + function dragended(d) { + d3.select(this).classed("dragging", false); + d3.select(this).classed("fixed", true); // TODO: this is broken since we override all the classes. Need to switch to class addition/removal (i.e. use classed for everything) or set class in one location (so here just set a value on the node, not the element) + if (d.dragstart.clientX - d3.event.sourceEvent.clientX != 0 || + d.dragstart.clientY - d3.event.sourceEvent.clientY != 0) { + tick(); + force.resume(); + } + } + + graph = new model_graph.Graph(); + + var user_id = $('#user_id'), + user = user_id.text(); + + if (user_id.length > 0) { + console.log('found user ID: \'' + user + '\''); + graph.set_user(user); + } + + var el = document.body; + vis = d3.select(el).append("svg:svg") + .attr('id', 'canvas_d3') + .attr("width", '100%') + .attr("height", '100%') + .attr("pointer-events", "all") + .append("g") + .attr("class", "zoom"); + + d3.select(el).select("svg").append("svg:defs") + .data(["end"]) // Different link/path types can be defined here + .append("svg:marker") // This section adds in the arrows + .attr("id", String) + .attr("viewBox", "0 -5 10 10") + .attr("refX", 22) + .attr("refY", -1.5) + .attr("markerWidth", 4) + .attr("markerHeight", 4) + .attr("orient", "auto") + .append("svg:path") + .attr("d", "M0,-5L10,0L0,5"); + + /* + * init zoom behavior + */ + var zoom_obj = d3.behavior.zoom().scaleExtent([0.1, 3]).on("zoom", zoom); + zoom_obj(d3.select('#canvas_d3')) + d3.select("svg").on("dblclick.zoom", null); // disable zoom on double click + + $('svg').click(svg_click_handler); + + // SVG rendering order is last rendered on top, so to make sure + // all links are below the nodes we group them under a single g + vis.append("g").attr("id", "link-group"); + vis.append("g").attr("id", "selected-link-group"); + + drag = d3.behavior.drag() + .origin(function(d) { return d; }) + .on("dragstart", dragstarted) + .on("drag", dragged) + .on("dragend", dragended); + + // $('#canvas_d3').dblclick(canvas_handler_dblclick); - see #138 + if (rz_config.backend_enabled){ + graph.load_from_backend( function(){ + update_view__graph(false); + }); + } +} + +function init_force_layout(){ + var el = document.body; + var w = $(el).innerWidth(), + h = $(el).innerHeight(); + + force = d3.layout.force() + .distance(120) + .gravity(0.12) + .charge(-1800) + .size([w, h]) + .on("tick", tick) + .start(); +} + +initDrawingArea(); +init_force_layout(); + +/** + * find the visual element counterpart of a given model object. This relies on + * the visual element having an id attribute equal to the object's id. + * + * @return null if visual element is not found + */ +function locate_visual_element(model_obj){ + var id_sel = $('#' + model_obj.id); + if (0 == id_sel.length){ + console.warn('unable to find visual element for model object: object id: ' + model_obj.id.toString()) + return null; + } + return id_sel[0]; +} + +/** + * add node on canvas double click + */ +function canvas_handler_dblclick(){ + var n = model_core.create_node__set_random_id(); + n.name = ''; // will be set by user + + graph.addNode(n); + update_view__graph(); + + var n_ve = locate_visual_element(n); // locate visual element + + var on_slowdown_cb = function(){ + svgInput.enable($(n_ve).find('.nodetext'), n); + observer.disconnect(); + } + var mutation_handler = rz_observer.new_Mutation_Handler__on_dxy_slowdown(on_slowdown_cb); + var observer = rz_observer.new_MutationObserver(mutation_handler); + mutation_handler.on_slowdown_threshold_reached; + + observer.observe(n_ve, { + subtree: false, + childList : false, + attributes: true, + attributeOldValue : true, + }); +} + +/** + * update view: graph + */ +function update_view__graph(no_relayout) { + var node, + link, + link_g, + linktext, + nodetext, + unselected_link_group = document.querySelector('#link-group'), + selected_link_group = document.querySelector('#selected-link-group'); + + link = vis.selectAll("g.link") + .data(graph.links(), function(d) { return d.id; }); + + link_g = link.enter().append('g') + .attr('id', function(d){ return d.id; }) // append link id to enable data->visual mapping + .attr('class', 'link graph') + + link_g.append("path") + .attr("class", function(d) { + return d.state + ' link graph'; + }) + .attr('id', function(d){ return d.id; }) // append link id to enable data->visual mapping + .attr("marker-end", "url(#end)"); + + // second path for larger click area + link_g.append("path") + .attr("class", "ghostlink") + .on("click", function(d, i) { + if (zoomInProgress) { + // don't disable zoomInProgress, it will be disabled by the svg_click_handler + // after this events bubbles to the svg element + return; + } + var that = this, + src = this.link.__src, + dst = this.link.__dst; + + view.edge_info.on_delete(function () { + graph.removeLink(that.link); + update_view__graph(true); + view.edge_info.hide(); + }); + view.edge_info.show(d); + selection.update([src, dst]); + update_view__graph(true); + }); + + link.attr("class", function(d, i){ + var temp_and = (d.name && d.name.replace(/ /g,"")=="and" && d.state==="temp") ? "temp_and" : ""; + + return ["graph link", temp_and, selection.selected_class(d)].join(' '); + }); + + link.selectAll('path.link') + .attr('class', function(d) { + return [d.state, selection.selected_class(d), "link graph"].join(' '); + }); + + link.exit().remove(); + + vis.selectAll('.ghostlink') + .data(graph.links()) + .each(function (d) { + this.link = d; + }); + + linktext = vis.selectAll(".linklabel") + .data(graph.links(), function(d) { return d.id; }); + linktext.enter() + .append("text") + .attr('id', function(d){ return d.id; }) // append link id to enable data->visual mapping + .attr("class", function(d) { + return ["linklabel graph", selection.selected_class(d)].join(' '); + }) + .attr("text-anchor", "middle") + .on("click", function(d, i) { + if (d.state !== "temp") { + svgInput.enable(this, d); + } + }); + + linktext + .text(function(d) { + var name = d.name || ""; + if (!(d.__dst.state === "temp" || + d.__src.state === "chosen" || d.__dst.state === "chosen")) { + return ""; + } + if (name.length < 25 || d.__src.state === "chosen" || + d.__dst.state === "chosen" || d.state==="temp") { + return name; + } else { + return name.substring(0, 14) + "..."; + } + }); + + linktext.exit().remove(); + + node = vis.selectAll(".node") + .data(graph.nodes(), function(d) { + return d.id; + }); + + var nodeEnter = node.enter() + .append("g") + .attr('id', function(d){ return d.id; }) // append node id to enable data->visual mapping + .attr('visibility', 'hidden') // made visible on first tick + .call(drag); + + // reorder nodes so selected are last, and so rendered last, and so on top. + (function () { + var ontop = [], + bubble; + + node.each(function (d) { + this.node = d; + }) + .attr('class', function(d) { + if (selection.node_selected(d)) { + if (d.type == 'bubble') { + bubble = this; + } else { + ontop.push(this); + } + } + return ['node', selection.selected_class(d)].join(' '); + }); + if (bubble === undefined) { + // nothing to do if there is no bubble + return; + } + function reparent(new_parent, element) { + if (element.parentNode == new_parent) { + return; + } + new_parent.appendChild(element); + } + // move link to correct group + // O(|links|*|ontop|) + link.each(function (d) { + if (ontop.some(function (node) { + var d_node = node.node; + return d.__src == d_node || d.__dst == d_node; + })) + { + reparent(selected_link_group, this); + } else { + reparent(unselected_link_group, this); + } + }); + linktext.each(function (d) { + if (selection.node_selected(d)) { + ontop.push(this); + } + }); + function moveToEnd(e) { + e.parentNode.appendChild(e); + } + moveToEnd(bubble); + ontop.reverse().forEach(function (e) { + moveToEnd(e); + }); + var count_links = function() { + return selected_link_group.childElementCount + unselected_link_group.childElementCount; + }; + // put back on top link group on top + selected_link_group.parentNode.insertBefore(selected_link_group, bubble.nextSibling); + })(); + + nodetext = nodeEnter.insert("text") + .attr("class", "nodetext graph") + .attr("dx", node_text_dx) + .attr("dy", node_text_dy) + .on("click", function(d, i) { + if (d3.event.defaultPrevented) { + // drag happened, ignore click https://github.com/mbostock/d3/wiki/Drag-Behavior#on + return; + } + if (d.state !== "temp") { + svgInput.enable(this, d); + selection.update([d]); + showNodeInfo(this.parentNode.node, i); + } + d3.event.stopPropagation(); + }); + + node.select('g.node text') + .text(function(d) { + if (!d.name) { + return d.type == 'bubble' ? "" : "_"; + } + if (d.state === "temp" || d.state === 'chosen' + || d.state === "enter" || d.state === "exit") { + return d.name; + } else { + if (d.name.length < 28) { + return d.name; + } else { + return d.name.substring(0, 25) + "..."; + } + } + }); + + circle = nodeEnter.insert("circle"); + node.select('g.node circle') + .attr("class", function(d) { + return d.type + " " + d.state + " circle graph"; + }) + .attr("r", function(d) { + return view_helpers.customSize(d.type) - 2; + }) + .on("click", function(d, i) { + if (d3.event.defaultPrevented) { + // drag happened, ignore click https://github.com/mbostock/d3/wiki/Drag-Behavior#on + return; + } + d3.event.stopPropagation(); + selection.update([d]); + if(d.state !== "temp") { + showNodeInfo(d, i); + } + }); + circle.append("svg:image") + .attr("class", "status graph") + .attr('x', -7) + .attr('y', -8) + .attr('width', 15) + .attr('height', 15) + .attr("xlink:href", function(d) { + switch (d.status) { + case "done": + return "res/img/check.png"; + break; + case "current": + return "res/img/wait.png"; + break; + case "waiting": + return "res/img/cross.png"; + break; + } + }); + + node.exit().remove(); + + //update deliverables + deliverables = []; + var nodes = graph.nodes(); + for (var i = 0; i < nodes.length; i++) { + var current = nodes[i]; + if (current.type === "third-internship-proposal") { + deliverables.push({ + "id": nodes[i].id, + "startdate": nodes[i].start, + "enddate": nodes[i].end + }); + } + //Do something + } + + force.nodes(graph.nodes()) + .links(graph.links()) + + if (no_relayout) { + // XXX If we are stopped we need to update the text of the links at least, + // and this is the simplest way + tick(); + } else { + force.alpha(0.1).start(); + } +} + + + +var debug_print = function(message) { + var element = $(".debug"); + if (element.length == 1) { + element.html(message); + } else { + console.log(message); + } +} + +function check_for_nan(x) { + if (Number.isNaN(x)) { + console.log('nan problem'); + force.stop(); + } + return Number.isNaN(x); +} + +var newnodes=1; +function tick(e) { + //console.log(e); + //$(".debug").html(force.alpha()); + var node = vis.selectAll(".node") + .data(force.nodes(), function(d) { + return d.id; + }); + var link = vis.selectAll("path.link") + .data(graph.links(), function(d) { + return d.id; + }); + var linktext = vis.selectAll(".linklabel").data(graph.links()); + + function transform(d) { + if (check_for_nan(d.x) || check_for_nan(d.y)) { + return; + } + return "translate(" + d.x + "," + d.y + ")"; + } + + //circles animation + var tempcounter = 0, + temptotal = graph.nodes().filter(function(d){ + return d.state === "temp" && d.type !== "chainlink" && d.type !== "bubble"; + }).length; + if (temptotal !== newnodes) { + newnodes += temptotal / 15 / (newnodes * newnodes); + } + newnodes = Math.max(1, Math.min(newnodes, temptotal)); + graph.nodes().forEach(function(d, i) { + var r, a; + if (d.state === "temp") { + tempcounter++; + if (d.type==="chainlink" || d.type==="bubble") { + d.x = window.innerWidth / 2; + d.y = window.innerHeight / 2; + } else { + r = 60 + newnodes * 20; + a = -Math.PI + Math.PI * 2 * (tempcounter-1) / newnodes + 0.3; + d.x = window.innerWidth / 2 + r * Math.cos(a); + d.y = window.innerHeight / 2 + r * Math.sin(a); + } + check_for_nan(d.x); + check_for_nan(d.y); + } + }); + + link.attr("d", function(d, i) { + var d_val, + ghost; + + var dx = d.__dst.x - d.__src.x, + dy = d.__dst.y - d.__src.y, + dr = Math.sqrt(dx * dx + dy * dy); + d_val = "M" + d.__src.x + "," + d.__src.y + "L" + d.__dst.x + "," + d.__dst.y; + // update ghostlink position + ghost = $(this.nextElementSibling); + ghost.attr("d", d_val); + return d_val; + }); + + linktext.attr("transform", function(d) { + return "translate(" + (d.__src.x + d.__dst.x) / 2 + "," + (d.__src.y + d.__dst.y) / 2 + ")"; + }); + + node.attr("transform", transform); + + // After initial placement we can make the nodes visible. + //links.attr('visibility', 'visible'); + node.attr('visibility', 'visible'); +} + +function showNodeInfo(d, i) { + view.node_info.on_save(function(e, form_data) { + + graph.update_node(d, form_data, function(){ + var old_type = d.type, + new_type = form_data.type; + + if (new_type != old_type) { + view.node_info.show(d); + } + + view.node_info.hide(); + update_view__graph(true); + }); + + return false; + }); + + view.node_info.on_delete(function() { + if (confirm('This node and all its connections will be deleted, are you sure?')) { + graph.removeNode(d.id); + update_view__graph(false); + view.node_info.hide(); + } + }); + + view.node_info.show(d); +} + +function svg_click_handler(e) { + if (zoomInProgress) { + zoomInProgress = false; + return; + } + if (e.originalEvent.target.nodeName != 'svg') { + return; + } + svgInput.hide(); + selection.clear(); + view.hide(); + update_view__graph(true); +} + +return { + graph: graph, + force: force, + load_from_json: function(result) { + graph.load_from_json(result); + recenterZoom(); + update_view__graph(false); + }, + update_view__graph : update_view__graph, +} +}); /* close define call */ diff --git a/src/client/rz_observer.js b/src/client/rz_observer.js new file mode 100644 index 00000000..718fd0a2 --- /dev/null +++ b/src/client/rz_observer.js @@ -0,0 +1,114 @@ +"use strict" + +/** + * DOM object observers + */ +define( +[], +function() { + +var MutationObserver = window.MutationObserver; +// more portable version: var MutationObserver = +// window.MutationObserver || +// window.WebKitMutationObserver; + +/** + * observe SVG object's translate attribute (x,y values), measure + * change rate & invoke on_slowdown_threshold_reached() upon + * reaching change slowdown threshold. + * + * Caller is responsible to disconnect observer. + */ +function Mutation_Handler__on_dxy_slowdown( + on_slowdown_threshold_reached) { + + var x_cur, y_cur, d, dx, dy, avg_d = 0; + + this.on_slowdown_threshold_reached = on_slowdown_threshold_reached; + + /** + * handle observer mutation + */ + this.handle_mutation = function(m) { + if ('transform' != m.attributeName || null == m.oldValue) { + return; + } + + /* + * parsed txt example: + * "translate(173.6007550157428,275.43228723527193)" + */ + var rgx_m = m.oldValue.match(/\((\d+.\d+),(\d+.\d+)\)/); + x_cur = rgx_m[1]; + y_cur = rgx_m[2]; + if (undefined == this.x_prv) { + this.x_prv = x_cur; + this.y_prv = y_cur; + return; + } + dx = x_cur - this.x_prv; + dy = y_cur - this.y_prv; + + var d = Math.sqrt(dx * dx + dy * dy); + // average across last samples + avg_d = (1.0 - this.most_recent_sample_weight) * avg_d + + this.most_recent_sample_weight * d; + + // debug + // console.log({ + // 'dx' : dx, + // 'dy' : dy, + // 'd' : d, + // 'avg_d' : avg_d, + // }); + + if (avg_d < this.slowdown_threshold) { + // slowdown threshold reached + if (this.on_slowdown_threshold_reached) { + this.on_slowdown_threshold_reached(); + } + } + + this.x_prv = x_cur; + this.y_prv = y_cur; + } +} + +function new_Mutation_Handler__on_dxy_slowdown( + on_slowdown_threshold_reached, slowdown_threshold, + most_recent_sample_weight) { + slowdown_threshold = slowdown_threshold || 0.07; + most_recent_sample_weight = most_recent_sample_weight || 0.3; + var ret = new Mutation_Handler__on_dxy_slowdown( + on_slowdown_threshold_reached); + ret.slowdown_threshold = slowdown_threshold; + ret.most_recent_sample_weight = most_recent_sample_weight; + return ret; +} + +function new_MutationObserver(handler) { + /* + * FIXME implemente with inheritence - currently triggers + * 'illegal invocation', possibly due to some interaction with + * requirejs + */ + // var o = new + // window.MutationObserver(Mutation_Observer.handler); + // var ret = Object.create(o); + // ret. = undefined; + // return ret; + var for_each_mutaion = function(m_set) { + m_set.forEach(function(m) { + handler.handle_mutation(m); + }) + }; + + return new MutationObserver(for_each_mutaion); +} + +return { + new_MutationObserver : new_MutationObserver, + new_Mutation_Handler__on_dxy_slowdown : new_Mutation_Handler__on_dxy_slowdown, +}; + +}); diff --git a/src/client/textanalysis.js b/src/client/textanalysis.js new file mode 100644 index 00000000..830c16c1 --- /dev/null +++ b/src/client/textanalysis.js @@ -0,0 +1,501 @@ +"use strict"; + +define(['rz_core', 'model/core', 'model/util', 'model/diff', 'rz_bus', 'consts'], +function(rz_core, model_core, model_util, model_diff, rz_bus, consts) { + +var typeindex = 0; +var nodetypes = consts.nodetypes; +var typeStack = []; + +var lastnode; + +var sugg = {}, // suggestions for autocompletion of node names + suggestions_options = new Bacon.Bus(); // TODO: Property: same as bus, but with initial value + +var ANALYSIS_NODE_START = 'ANALYSIS_NODE_START'; +var ANALYSIS_NODE = 'ANALYSIS_NODE' +var ANALYSIS_LINK = 'ANALYSIS_LINK'; + +function selectedType() +{ + return nodetypes[typeindex]; +} + +function autoSuggestAddName(name) +{ + /* note that name can contain spaces - this is ok. We might want to limit this though? */ + sugg[name] = 1; + suggestions_options.push(sugg); +} + +/* up_to_two_renames: + * + * allow one letter or 'new node' to anything changes */ +function up_to_two_renames(graph, old_name, new_name) +{ + var not_one_letter = false; + var k; + /* Allowed renames: + * no change + * s1 is substring of s2 + * older (s1) node being 'new node' + */ + function allowed_rename(s1, s2) + { + return (s1 == s2 || + s1 == 'new node' || + s1.substr(0, s2.length) == s2 || + s2.substr(0, s1.length) == s1); + } + + if (old_name.length != new_name.length) { + console.log('bug: up_to_two_renames: not equal inputs'); + return; + } + if (old_name.length > 2) { + console.log('bug: up_to_two_renames: input length 2 < ' + old_name.length); + return; + } + if (old_name.length == 2) { + if (allowed_rename(old_name[0], new_name[1]) && + allowed_rename(old_name[1], new_name[0])) { + old_name = [old_name[1], old_name[0]]; + } else { + if (!allowed_rename(old_name[0], new_name[0]) || + !allowed_rename(old_name[1], new_name[1])) { + not_one_letter = true; + } + } + } + if (not_one_letter) { + console.log('bug: up_to_two_renames: not one letter changes'); + console.log(old_name); + console.log(new_name); + return; + } + for (k = 0 ; k < old_name.length ; ++k) { + graph.editNameByName(old_name[k], new_name[k]); + } +} + +// TODO: add escape char, i.e. r"bla\"bla" -> ['bla"bla'] +function tokenize(text, node_token, quote) +{ + var segment = [], + subsegment = [], + sentence = [], + quoteword; + var j; + + segment = text.split(node_token); + for (j = 0; j < segment.length; j++) { + if (j !== 0) sentence.push(node_token); + subsegment = segment[j].split(" "); + if (subsegment.length === 0) { + sentence.push(" "); + } + for (var k = 0; k < subsegment.length; k++) { + if (subsegment[k] !== " " && subsegment[k] !== "") { + if (subsegment[k].charAt(0) === quote) { + quoteword = ""; + do { + quoteword += subsegment[k] + ' '; + if(subsegment[k].charAt(subsegment[k].length-1) !== quote)k++; + } while (k < subsegment.length && subsegment[k].charAt(subsegment[k].length - 1) !== quote); + if (subsegment[k] && subsegment[k]!==quoteword.replace(/ /g, "")) { + quoteword += subsegment[k]; + } + sentence.push(quoteword.replace(new RegExp(quote, 'g'), "")); + } else { + sentence.push(subsegment[k]); + } + } + } + } + return sentence; +} + +/* + * textAnalyser + * + * Input: + * @newtext - new sentence + * @finalize - is this an intermediate editing state or are we editing the graph + * + * Output: + * none + * + * Side effect: + * updating graph (global) + * + * Implementation notes: + * There is no well defined grammer. The translation goes from obvious to not + * so much for more complex sentences involving more than two nodes (two '#' + * marks). + * + */ +var textAnalyser = function (newtext, finalize) { + var sentence, + token_set_new_node_names = [], // token set representing new node names + token_set_new_link_names = [], // token set representing new link names + linkindex = 0, + nodeindex = 0, + orderStack = [], + and_count = 0, + prefix = "", + m, + word, + completeSentence, + typesetter, starGraph, + n, + link_hash = {}, + yell_bug = false, // TODO: fix both issues + NODE = "NODE", + LINK = "LINK", + START = "START", + ret = model_diff.new_topo_diff(); + + function addNode(name, type, state) { + if (type === undefined) { + console.log('bug: textanalyser.addNode of type undefined'); + } + var node = model_core.create_node_from_spec( + {'name':name, + 'type':type, + 'state':state}); + + ret.node_set_add.push(node); + } + + function __addLink(src, dst, name, state) { + if (!src || !dst) { + if (yell_bug) { + console.log('bug - adding link (' + src + ', ' + dst + ')'); + } + return; + } + if (link_hash[src] && link_hash[src][dst]) { + if (yell_bug) { + console.log('bug - adding link twice (' + src + ', ' + dst + ')'); + } + return; + } + if (!link_hash[src]) { + link_hash[src] = {}; + } + link_hash[src][dst] = 1; + + var link = {'__src': src, '__dst':dst, 'name':name, 'state':state}; // can't use model_core.create_link_from_spec as src,dst are only names + ret.link_set_add.push(link); + } + + function apply_conjugator_link_logic(link, drop_conjugator_links){ + if (drop_conjugator_links && link.name && (link.name.replace(/ /g,"") === "and")) { + link.state = "temp"; + } + } + + if (newtext.indexOf('#') == -1 || finalize) { + lastnode = null; + } + + //Sentence Sequencing + //Build the words and cuts the main elements + sentence = tokenize(newtext, '#', '"'); + + // build new node,link arrays in order of appearance + for (m = 0; m < sentence.length; m++) { + switch (sentence[m]) { + case "#": + orderStack.push(START); + break; + case "and": + case "+": + case ",": + case "&": + sentence[m] = "and"; + and_count++; + //orderStack.push("AND"); + default: + if (orderStack[orderStack.length - 1] === START) { + orderStack.push(NODE); + token_set_new_node_names.push(sentence[m]); + linkindex++; + } else if (orderStack[orderStack.length - 1] === NODE) { + orderStack.push(LINK); + if (!token_set_new_link_names[linkindex]) { + token_set_new_link_names[linkindex] = sentence[m] + " "; + } else { + token_set_new_link_names[linkindex] += sentence[m] + " "; + } + } else { + if (!token_set_new_link_names[linkindex]) { + token_set_new_link_names[linkindex] = sentence[m] + " "; + } else { + token_set_new_link_names[linkindex] += sentence[m] + " "; + } + } + if (token_set_new_node_names.length === 0) { + prefix += (prefix.length > 0 ? ' ' : '') + sentence[m]; + } + break; + } + } + + starGraph = (token_set_new_link_names.length - and_count) >= 3 || + ((token_set_new_link_names.length - and_count >= 1) && + token_set_new_link_names.length > 2 && + orderStack.length > 1 && + orderStack[orderStack.length - 1] != NODE); + + //PREFIX not null case - put complete sentence in first link. + if (prefix && !starGraph) { + token_set_new_link_names[1] = prefix + " " + token_set_new_node_names[0] + + (token_set_new_link_names[1] !== undefined || token_set_new_node_names[1] !== undefined ? + " " : "") + + (token_set_new_link_names[1] !== undefined ? token_set_new_link_names[1] : "") + + (token_set_new_node_names[1] !== undefined ? token_set_new_node_names[1] : ""); + } + + //WRITE COMPLETE SENTENCE + linkindex = 0; + nodeindex = 0; + word = ""; + completeSentence = prefix.length > 0 ? String(prefix) + " " : ""; + for (m = 0; m < orderStack.length; m++) { + if (orderStack[m] === NODE) { + word += " (" + token_set_new_node_names[nodeindex] + ") "; + completeSentence += token_set_new_node_names[nodeindex] + " "; + nodeindex++; + } else if (orderStack[m] === LINK) { + word += " -->" + token_set_new_link_names[nodeindex] + " --> "; + completeSentence += token_set_new_link_names[nodeindex]; + } + } + completeSentence = completeSentence.trim(); + + //REBUILD GRAPH + linkindex = 0; + nodeindex = 0; + + //CHANGE TO PERMANENT STATE AND UPDATE SUGGESTIONLIST + typesetter = ""; + if (finalize === true) { + typesetter = "perm"; + for (n = 0; n < token_set_new_node_names.length; n++) { + autoSuggestAddName(token_set_new_node_names[n]); + } + } else { + typesetter = "temp"; + } + + //ADD SURROUNDING BUBBLE + if (orderStack.length > 0) { + addNode("", "bubble","temp"); + } + + //0-N ORDER STACK + for (m = 0; m < orderStack.length - 1; m++) { + switch (orderStack[m]) { + case START: + if (!typeStack[nodeindex]) { + typeStack[nodeindex] = selectedType(); + } + break; + case NODE: + addNode(token_set_new_node_names[nodeindex], typeStack[nodeindex], typesetter); + if (!starGraph && nodeindex > 0) { + __addLink(token_set_new_node_names[nodeindex - 1], + token_set_new_node_names[nodeindex], + token_set_new_link_names[linkindex], typesetter); + } + nodeindex++; + break; + case LINK: + linkindex++; + break; + } + } + + //FINAL N ORDER + switch (orderStack[orderStack.length - 1]) { + case START: + typeStack[nodeindex] = selectedType(); + addNode("new node", typeStack[nodeindex], "temp"); + if (!starGraph && nodeindex > 0) { + __addLink(token_set_new_node_names[nodeindex - 1], "new node", + token_set_new_link_names[linkindex], "temp"); + and_connect("new node"); + } + ret.state = ANALYSIS_NODE_START; + break; + case NODE: + typeStack[nodeindex] = selectedType(); + addNode(token_set_new_node_names[nodeindex], typeStack[nodeindex], typesetter); + if (!starGraph && nodeindex > 0) { + __addLink(token_set_new_node_names[nodeindex - 1], + token_set_new_node_names[nodeindex], + token_set_new_link_names[linkindex], typesetter); + and_connect(token_set_new_node_names[nodeindex]); + } + ret.state = ANALYSIS_NODE_START; + break; + case LINK: + linkindex++; + addNode("new node", selectedType(), "temp"); + if (!starGraph) { + __addLink(token_set_new_node_names[nodeindex - 1], "new node", token_set_new_link_names[linkindex], "temp"); + and_connect("new node"); + } + ret.state = ANALYSIS_LINK; + break; + } + + //EXTERNAL AND CONNECTION CHECKING + function and_connect(node) { + var verb; + for(var x=0;x<token_set_new_link_names.length;x++){ + if(token_set_new_link_names[x])if(token_set_new_link_names[x].replace(/ /g,"")!=="and"){ + verb = token_set_new_link_names[x]; + for(var y=0; y<x ;y++){ + __addLink(token_set_new_node_names[y], node, verb, typesetter); + for(var z=x; z<token_set_new_node_names.length ;z++){ + __addLink(token_set_new_node_names[y], token_set_new_node_names[z], verb, typesetter); + } + } + } + } + } + + /*console.log(sentence); + console.log(completeSentence); + console.log(orderStack);*/ + + //STAR CASE + if (starGraph) { + addNode(completeSentence, "chainlink", typesetter); + for (n = 0; n < token_set_new_node_names.length; n++) { + __addLink(token_set_new_node_names[n], completeSentence, "", typesetter); + } + } + + ret.drop_conjugator_links = and_count < linkindex; + + ret.applyToGraph = function(graph, backend_commit) { + window.ret = ret; + + /* + * generate fitered node set who: + * - are not name-present in graph + * - are not of type 'bubble' + */ + var n_set = ret.node_set_add.filter(function(node) { + return false == graph.hasNodeByNameAndNotState(node.name, "temp") + && node.type !== 'bubble'; + }), + link_set = ret.link_set_add.map( + function (link) { + return [link.__src, link.__dst]; + }), + comp = graph.compareSubset('temp', n_set, link_set); + + if (false == finalize && comp.graph_same) { + if (comp.old_name && comp.new_name) { + up_to_two_renames(graph, comp.old_name, comp.new_name); + } + } else { + // REINITIALISE GRAPH (DUMB BUT IT WORKS) + graph.removeNodes(function(n){ return "temp" == n.state; }); + graph.removeLinks("temp"); + + ret.for_each_node_add(function (node) { + if (true == finalize && node.state == 'temp') { + console.log('bug: temp node creation on finalize'); + } else { + if (!finalize) { + lastnode = graph.addNode(node); + } else { + graph.addNode(node); + } + } + }); + } + ret.for_each_link_add(function (link) { + if (false == finalize || link.name !== 'and') { + apply_conjugator_link_logic(link, ret.drop_conjugator_links); + graph.addLinkByName(link.__src, + link.__dst, + link.name, + link.state); + } + }); + + if (!finalize) { + graph.markRelated(token_set_new_node_names); + } else { + graph.removeRelated(); + } + + if (finalize && backend_commit) { + // broadcast diff: + // - finalize? + // - broadcast_diff requested by caller + var topo_diff = model_util.adapt_format_write_topo_diff(ret.nodes, ret.links); + var diff_set = model_diff.new_diff_set(); + diff_set.add_diff_obj(topo_diff); + graph.commit_diff_set(diff_set); + } + + // UPDATE GRAPH ONCE + rz_core.update_view__graph(!finalize && comp.graph_same); + }; + + if (finalize) { + typeStack = []; + } + + return ret; +}; + +function init(graph) +{ + function onNodeAdded(diff) { + if (!diff || !diff.nodes || !diff.nodes.added) { + return; + } + for (var k in diff.nodes.added) { + var node = diff.nodes[k]; + autoSuggestAddName(node.name.toLowerCase()); + } + } + function toLowerCase(n) { + return n.toLowerCase(); + } + function onSuggestedNameAdd(names) { + names.map(toLowerCase).forEach(autoSuggestAddName); + } + graph.diffBus.onValue(onNodeAdded); + rz_bus.names.onValue(onSuggestedNameAdd); +} + +return { + init:init, + textAnalyser:textAnalyser, + suggestions_options: suggestions_options, + ANALYSIS_NODE_START:ANALYSIS_NODE_START, + ANALYSIS_NODE: ANALYSIS_NODE, + ANALYSIS_LINK:ANALYSIS_LINK, + + //for the external arrow-type changer + lastnode: function() { return lastnode; }, + + selected_type_next: function() { + typeindex = (typeindex + 1) % 5; + return selectedType(); + }, + selected_type_prev: function() { + typeindex = (typeindex + 4) % 5; + return selectedType(); + } +}; +}); diff --git a/src/client/textanalysis.ui.js b/src/client/textanalysis.ui.js new file mode 100644 index 00000000..5f5718bd --- /dev/null +++ b/src/client/textanalysis.ui.js @@ -0,0 +1,195 @@ +"use strict" + +define(['jquery', 'Bacon', 'consts', 'rz_bus', 'rz_core', 'textanalysis', 'view/completer'], +function($, Bacon, consts, rz_bus, rz_core, textanalysis, completer) { + +var text = "", // Last text of sentence + element_name = '#textanalyser', + element = $(element_name), + element_raw = element[0], + plus_button = $('.add-button'), + description = consts.description; + +var typeselection = function TypeSelectionDialog() { + var e = $('.typeselection'), + e_intro = e.find('#intro'), + e_label = e.find('#chosentypelabel'), + e_name = e.find('#chosentypename'), + e_desc = e.find('#chosentypedesc'), + typeselection = {}; + + typeselection.analysisNodeStart = function() { + typeselection.show(); + } + typeselection.show = function() { + e.css({ + top: window.innerHeight / 2 - 115, + left: window.innerWidth / 2 - 325 + }); + e_label.hide(); + e_desc.hide(); + e_intro.show(); + e.show(); + } + typeselection.hide = function() { + e.hide(); + } + typeselection.showChosenType = function(nodetype) { + var desc = description[nodetype]; + + e_intro.hide(); + e_label.show(); + e_name.html(nodetype); + if (desc) { + e_desc.html(description[nodetype]); + e_desc.show(); + } else { + e_desc.hide(); + } + } + return typeselection; +}(); + +var analysisCompleter = completer(element, $('#input-suggestion'), {hideOnTab: false}); + +function analyzeSentence(sentence, finalize) +{ + var ret = textanalysis.textAnalyser(sentence, finalize); + + switch (ret.state) { + case textanalysis.ANALYSIS_NODE_START: + typeselection.analysisNodeStart(); + break; + case textanalysis.ANALYSIS_LINK: + typeselection.hide(); + break; + } + + var backend_commit = false; + ret.applyToGraph(rz_core.graph, backend_commit); + + if (finalize || sentence.length == 0) { + typeselection.hide(); + } +} + +function textSelect(inp, s, e) { + e = e || s; + if (inp.createTextRange) { + var r = inp.createTextRange(); + r.collapse(true); + r.moveEnd('character', e); + r.moveStart('character', s); + r.select(); + }else if(inp.setSelectionRange) { + inp.focus(); + inp.setSelectionRange(s, e); + } +} + +function changeType(arg) { + var lastnode = textanalysis.lastnode(), + nodetype, + id; + + if(!lastnode) { + id = "new node"; + } else { + id = lastnode.id; + } + nodetype = (arg === 'up'? textanalysis.selected_type_next() : textanalysis.selected_type_prev()); + + if (arg === 'up') { + rz_core.graph.editType(id, null, nodetype); + typeselection.showChosenType(nodetype); + rz_core.graph.findCoordinates(id); + } else { + rz_core.graph.editType(id, null, nodetype); + typeselection.showChosenType(nodetype); + rz_core.graph.findCoordinates(id); + } + rz_core.update_view__graph(true); +} + +return { + analyzeSentence: analyzeSentence, + main:function () { + if (element.length != 1) { + return; + } + + analysisCompleter.options.plug(textanalysis.suggestions_options); + + var document_keydown = new Bacon.Bus(); + rz_bus.ui_key.plug(document_keydown); + + element.keydown(function(e) { + var ret = undefined; + + switch (e.keyCode) { + case 13: + if (!analysisCompleter.handleEnter()) { + submitNewSentence(); + } else { + analyzeSentence(element.val(), false); + } + ret = false; + break; + case 9: //TAB + if (textanalysis.lastnode()) { + e.preventDefault(); + changeType(e.shiftKey ? "up" : "down", textanalysis.lastnode()); + ret = false; + } + break; + } + document_keydown.push({where: consts.KEYSTROKE_WHERE_TEXTANALYSIS, keys: [e.keyCode]}); + return ret; + }); + element.bind('input selectionchange click', function() { + analysisCompleter.oninput(element_raw.value, element_raw.selectionStart); + }); + + function submitNewSentence() { + text = element.val(); + element.val(""); + analyzeSentence(text, true); + text = ""; + } + + // Click is required to prevent the default action - this is a form so that's a post, + // and away we go. + // The mousedown is required because CSS3 transitions eat some events sometimes. This is + // the closest I've come to an explanation: + // http://stackoverflow.com/questions/15786891/browser-sometimes-ignores-a-jquery-click-event-during-a-css3-transform + plus_button.bind("click mousedown", function(e) { + console.dir(e); + submitNewSentence(); + e.preventDefault(); + }); + + var input = new Bacon.Bus(); + rz_bus.ui_input.plug(input); + if ('oninput' in document.documentElement) { + element.on('input', function(e) { + text = element.val(); + analyzeSentence(text, false); + input.push({where: consts.INPUT_WHERE_TEXTANALYSIS, input: text}); + }); + } else { + console.log('textanalysis.ui: fallback to polling'); + window.setInterval(function() { + if (element.val() != text) { + if (text.length * 8 > 500) { + element.css('width', text.length * 8 + 20); + } + // text changed + text = element.val(); + analyzeSentence(text, false); + suggestionChange = false; + } + }, 50); + } + } +}; +}); // define diff --git a/src/client/util.js b/src/client/util.js new file mode 100644 index 00000000..3fd4aecb --- /dev/null +++ b/src/client/util.js @@ -0,0 +1,72 @@ +"use strict" + +define(function() { + + function assert(condition, message) { + if (false == condition) { + message = message || "Assertion failed"; + if (typeof Error !== "undefined") { + throw new Error(message); + } + throw message; // Fallback + } + } + + function set_from_array(a) { + var ret = {}; + for (var k = 0; k < a.length; ++k) { + ret[a[k]] = 1; + } + return ret; + } + + function set_from_object(o) { + var ret = {} + for ( var k in o) { + ret[k] = 1; + } + return ret; + } + + function set_diff(sa, sb) { + var ret = { + a_b : [], + b_a : [] + }; + var i; + for (i in sa) { + if (!(i in sb)) { + ret.a_b.push(i); + } + } + for (i in sb) { + if (!(i in sa)) { + ret.b_a.push(i); + } + } + return ret; + } + + function array_diff(aa, ab) { + var sa = set_from_array(aa); + var sb = set_from_array(ab); + return set_diff(sa, sb); + } + + // TODO: jquery BBQ: $.deparam.querystring().json; + function getParameterByName(name) { + name = name.replace(/[\[]/, "\\[").replace(/[\]]/, "\\]"); + var regex = new RegExp("[\\?&]" + name + "=([^&#]*)"), + results = regex.exec(location.search); + return results === null ? "" : decodeURIComponent(results[1].replace(/\+/g, " ")); + } + + return { + assert: assert, + set_from_array: set_from_array, + set_from_object: set_from_object, + set_diff: set_diff, + array_diff: array_diff, + getParameterByName: getParameterByName, + }; +}); diff --git a/src/client/view/completer.js b/src/client/view/completer.js new file mode 100644 index 00000000..d2593321 --- /dev/null +++ b/src/client/view/completer.js @@ -0,0 +1,229 @@ +define( +['jquery', 'Bacon'], +function($, Bacon) { + +function unquoted(name) +{ + var start = 0, + end = name.length; + + if (name.length >= 1) { + if (name.charAt(0) == '"') { + start = 1; + if (name.length > 1 && name.charAt(name.length - 1) == '"') { + end = name.length - 1; + } + } + return name.substring(start, end); + } + return name; +} + +function setCaret(e, num) +{ + e.selectionStart = e.selectionEnd = num; +} + +var completer = (function (input_element, dropdown, base_config) { + var config = get_config(base_config), + dropdown_raw = dropdown[0], + options_bus = new Bacon.Bus(), + options = [], + selected_index = -1, + input_element_raw = input_element[0], + completion_start = 0, + completion_end = 0, + minimum_length = 1; + + // turn off the browser's autocomplete + input_element.attr('autocomplete', 'off'); + + //$('.ui-autocomplete').css('width', '10px'); + options_bus.onValue(function update_options(new_options) { + options = new_options; + }); + + input_element.keyup(function(e) { + var ret = undefined; + switch (e.keyCode) { + case 38: //UP + prev_option(); + ret = false; + break; + case 40: //DOWN + next_option(); + ret = false; + break; + case 27: // Escape + hide(); + ret = false; + break; + default: + // This catches cursor move due to keyboard events. no event for cursor movement itself + // below we catch cursor moves due to mouse click + oninput(input_element_raw.value, input_element_raw.selectionStart); + } + return ret; + }); + input_element.keydown(function(e) { + switch (e.keyCode) { + case 38: + case 40: + return false; + case 9: // Tab + if (config.hideOnTab) { + hide(); + } + break; + } + }); + + function get_config(base) { + return { + triggerStart: base && base.triggerStart || '#', + triggerEnd: base && base.triggerEnd || ' ', + hideOnTab: base && base.hasOwnProperty('hideOnTab') ? base.hideOnTab : true, + }; + } + + function completions(text) + { + var ret = [], + noquotes = unquoted(text.toLowerCase()); + + for (var name in options) { + if (name.toLowerCase().indexOf(noquotes) === 0) { + ret.push(name); + } + } + return ret; + } + + function show() { + if (dropdown.children().length > 0) { + dropdown.show(); + } + } + function hide() + { + dropdown.hide(); + } + + /*** + * #this is a # + * ^ + * + * #this is a #t + * ^ + * + * #this and #that then #he + * ^ + */ + function oninput(text, cursor) { + var hash = text.slice(0, cursor).lastIndexOf(config.triggerStart); + // TODO check if current completion has been invalidated + _invalidateSelection(); + hide(); + dropdown_raw.innerHTML = ""; // remove all elements + if (hash == -1 && config.triggerStart != ' ') { // space matches start of string too + return; + } + var space = text.slice(hash + 1).indexOf(config.triggerEnd); + space = space == -1 ? text.length : space; + if (space < cursor) { + return; + } + completion_start = hash + 1; + completion_end = space; + var string = text.slice(completion_start, completion_end); + if (string.length < minimum_length) { + return; + } + completions(string).forEach(function(name) { + var suggestion = $('<div class="suggestion-item">' + name + '</div>'); + suggestion.on('click', function(e) { + _applySuggestion(name); + input_element.focus(); + }); + dropdown.append(suggestion); + }); + show(); + } + + function _invalidateSelection() { + update_highlighting(-1); + } + + function _move_option(change, default_value) { + var next, + n = dropdown.children().length; + + if (n == 0) { + return; + } + show(); + if (selected_index == -1) { + next = default_value; + } else { + next = (selected_index + change) % n; + } + update_highlighting(next); + } + function next_option() { + _move_option(1, 0); + } + function prev_option() { + _move_option(dropdown.children().length - 1, dropdown.children().length - 1); + } + function _get_option(index) { + if (dropdown.children().length <= index) { + console.log('error: dropdown does not contain index ' + index + + ', it has ' + dropdown.children().length + ' elements'); + return ''; + } + var e = dropdown.children()[index], + s = e.innerText || e.textContent; + if (s.indexOf(' ') != -1) { + return '"' + s + '"'; + } + return s; + } + function _choice(i) { + return dropdown.children().eq(i); + } + function update_highlighting(new_index) { + if (selected_index != -1) { + _choice(selected_index).removeClass('selected'); + } + if (new_index != -1) { + _choice(new_index).addClass('selected'); + } + selected_index = new_index; + } + function _applySuggestion(str) { + var cur = input_element.val(), + start = cur.slice(0, completion_start) + str + ' '; + input_element.val(start + cur.slice(completion_end)); + setCaret(input_element, start.length); + oninput('', 0); + } + function handleEnter() { + if (selected_index == -1) { + return false; + } + _applySuggestion(_get_option(selected_index)); + return true; + } + + return { + options: options_bus, + oninput: oninput, + next_option: next_option, + prev_option: prev_option, + handleEnter: handleEnter, + }; +}); + +return completer; + +}); diff --git a/src/client/view/edge_info.js b/src/client/view/edge_info.js new file mode 100644 index 00000000..d711e200 --- /dev/null +++ b/src/client/view/edge_info.js @@ -0,0 +1,36 @@ +"use strict" + +define(['view/internal'], +function(internal) { + +var delete_button = internal.edit_tab.get('edge', '#deleteedge'), + delete_callback = undefined; + +delete_button.on('click', function() { + if (delete_callback) { + delete_callback(); + } +}); + +function show(link) +{ + internal.edit_tab.show('edge'); + internal.edit_tab.get('edge', '#edgetitle').html(link.name); +} + +function hide() +{ + internal.edit_tab.hide(); +} + +function on_delete(f) +{ + delete_callback = f; +} + +return { + show: show, + hide: hide, + on_delete: on_delete, +}; +}); diff --git a/src/client/view/helpers.js b/src/client/view/helpers.js new file mode 100644 index 00000000..ad11763a --- /dev/null +++ b/src/client/view/helpers.js @@ -0,0 +1,87 @@ +"use strict" + +define(function() { +function customColor(type) { + var color; + switch (type) { + case "person": + color = '#FCB924'; //blue + break; + case "club": + color = '#ee3654'; //magenta + break; + case "skill": + color = '#fad900'; //yellow + break; + case "third-internship-proposal": + color = '#33c2e0'; //cyan + break; + case "internship": + color = '#ff8b11'; //orange + break; + case "interest": + color = '#8b3ab0'; //purple + break; + case "project": + color = "#40C200"; //green + break; + case "empty": + color = "#919095"; //mid-grey + break; + case "chainlink": + color = "#363636"; //dark-grey + break; + case "bubble": + color = "rgba(255,255,255,0.2)"; // white, 0.2 opaque + break; + default: + console.log('bug: unknown type ' + type); + color = '#d4d4d9'; //mid-light grey + break; + } + return color; +} + +function customSize(type) { + var size; + switch (type) { + case "person": + size = 12; + break; + case "club": + size = 12; + break; + case "skill": + size = 12; + break; + case "third-internship-proposal": + size = 12; + break; + case "internship": + size = 12; + break; + case "interest": + size = 12; + break; + case "empty": + size = 9; + break; + case "chainlink": + size = 8; + break; + case "bubble": + size = 180; + break; + default: + size = 9; + break; + } + return size; +} + + +return { + customSize: customSize, + customColor: customColor, +}; +}); diff --git a/src/client/view/internal.js b/src/client/view/internal.js new file mode 100644 index 00000000..bc18bd75 --- /dev/null +++ b/src/client/view/internal.js @@ -0,0 +1,14 @@ +define(['view/tab'], +function(tab) { + +var EDGE_INFO_SELECTOR = '.edge_info', + NODE_INFO_SELECTOR = '.info', + edit_tab = new tab.Tab({edge: EDGE_INFO_SELECTOR, node: NODE_INFO_SELECTOR}); + +return { + EDGE_INFO_SELECTOR: EDGE_INFO_SELECTOR, + NODE_INFO_SELECTOR: NODE_INFO_SELECTOR, + edit_tab: edit_tab, +}; + +}); diff --git a/src/client/view/node_info.js b/src/client/view/node_info.js new file mode 100644 index 00000000..44f2fab8 --- /dev/null +++ b/src/client/view/node_info.js @@ -0,0 +1,117 @@ +define(['jquery', 'jquery-ui', 'view/helpers', 'view/internal'], +function($, _unused_jquery_ui, view_helpers, internal) { + +var d = null, + submit_callback = null, + delete_callback = null; + +function _get_form_data() { + return { + name: $('.info #editformname').val(), + type: $('.info #edittype').val(), + url: $('.info #editurl').val(), + status: $('.info #editstatus').val(), + startdate: $("#editstartdate").val(), + enddate: $("#editenddate").val(), + }; +} + +//internal.edit_tab.get('node', "#editbox").submit(function(e) { +// if (submit_callback) { +// return submit_callback(e, _get_form_data()); +// } +// console.log('bug: edit tab submit called with no callback set'); +// e.preventDefault(); +//}) + +//internal.edit_tab.get('node', "#deletenode").click(function(e) { +// if (delete_callback) { +// return delete_callback(e, _get_form_data()); +// } +// console.log('bug: edit tab delete called with no callback set'); +// e.preventDefault(); +//}); + +function show(d) { + var info = $('.info'), + f = false, + t = true, + visible = { + "third-internship-proposal": [t, t, t, f, f], + "chainlink": [f, f, f, f, f], + "skill": [f, f, f, f, t], + "interest": [f, f, f, f, t], + "_defaults": [f, f, f, f, t], + }, + fields = ["#status", "#startdate", "#enddate", "#desc", "#url"], + flags = visible.hasOwnProperty(d.type) ? visible[d.type] : visible._defaults, + i; + + internal.edit_tab.show('node'); + + for (i = 0 ; i < flags.length; ++i) { + var elem = info.find(fields[i]); + elem[flags[i] ? 'show' : 'hide'](); + } + + $('.info').attr('class', 'info'); + $('.info').addClass('type-' + d.type); // Add a class to distinguish types for css + + $('.info').find('#editformname').val(d.name); + $("#editenddate").datepicker({ + inline: true, + showOtherMonths: true, + dayNamesMin: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], + }); + $("#editstartdate").datepicker({ + inline: true, + showOtherMonths: true, + dayNamesMin: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], + }); + + $('#editdescription').val(d.type); + + $('#edittype').val(d.type); + + $('#editurl').val(d.url); + + $('#editstatus').val(d.status); + + if (d.type === "third-internship-proposal") { + $('#editstartdate').val(d.start); + $('#editenddate').val(d.end); + } +} + +function hide() +{ + internal.edit_tab.hide(); +} + +function on_save(f) { + $('#edit-node-dialog__save').click(function(e) { + return f(e, _get_form_data()); + }); +} + +function on_delete(f) { + $('#edit-node-dialog__delete').click(function(e) { + return f(e, _get_form_data()); + }); +} + +function on_keyup(f) { + $('.info').keyup(function(e) { + return f(e, _get_form_data()); + }); +} + +return { + show: show, + hide: hide, + on_save: on_save, + on_delete: on_delete, + on_keyup: on_keyup, +}; + +}); diff --git a/src/client/view/selection.js b/src/client/view/selection.js new file mode 100644 index 00000000..d51d1a4b --- /dev/null +++ b/src/client/view/selection.js @@ -0,0 +1,102 @@ +define(['rz_core'], +function(rz_core) { + +function get_rz_core() +{ + // circular dependency on rz_core, so require.js cannot solve it. + if (rz_core === undefined) { + rz_core = require('rz_core'); + } + return rz_core; +} + +var selected_nodes = []; + +function byVisitors(node_selector, link_selector) { + var new_selected_nodes = get_rz_core().graph.findByVisitors(node_selector, link_selector); + + clear(); + connectedComponent(new_selected_nodes); +} + +function connectedComponent(nodes) { + var connected = get_rz_core().graph.getConnectedNodesAndLinks(nodes, 1), + i, + node, + link, + data; + + selected_nodes = nodes.map(function(x) { return x; }); + + for (i = 0 ; i < connected.nodes.length ; ++i) { + data = connected.nodes[i]; + node = data.node; + switch (data.type) { + case 'exit': + node.state = 'exit'; + break; + case 'enter': + node.state = 'enter'; + break; + }; + } + for (i = 0 ; i < connected.links.length ; ++i) { + data = connected.links[i]; + link = data.link; + switch (data.type) { + case 'exit': + link.state = 'exit'; + break; + case 'enter': + link.state = 'enter'; + break; + }; + } + nodes.forEach(function (n) { n.state = 'chosen'; }); +} + +var node_selected = function(node) { + return node.state == 'chosen' || node.state == 'enter' || node.state == 'exit' || node.state == 'selected' + || node.state == 'temp' || node.state == 'related'; +} + +var selected_class = function(node) { + return selected_nodes.length > 0 ? (node_selected(node) ? "selected" : "notselected") : ""; +} + +var clear = function() { + selected_nodes.length = 0; + get_rz_core().graph.setRegularState(); +} + +function arr_compare(a1, a2) +{ + if (a1.length != a2.length) { + return false; + } + for (var i = 0 ; i < a1.length ; ++i) { + if (a1[i] != a2[i]) { + return false; + } + } + return true; +} + +var update = function(nodes) { + var set = !arr_compare(nodes, selected_nodes); + clear(); + if (set) { + connectedComponent(nodes); + } +} + +return { + byVisitors: byVisitors, + connectedComponent: connectedComponent, + clear: clear, + update: update, + selected_class: selected_class, + node_selected: node_selected, +}; + +}); diff --git a/src/client/view/tab.js b/src/client/view/tab.js new file mode 100644 index 00000000..97ba1351 --- /dev/null +++ b/src/client/view/tab.js @@ -0,0 +1,57 @@ +"use strict" + +define(['jquery'], +function($) { + +function Tab(dict) { + var k, + selector = {}, + name = []; + + for (k in dict) { + if (dict.hasOwnProperty(k) == false) { + continue; + } + selector[k] = dict[k]; + name.push(k); + } + this._selector = selector; + this._name = name; +} + +Tab.prototype.show = function(shown_name) { + var i, + name, + element; + + for (i = 0 ; i < this._name.length ; ++i) { + name = this._name[i]; + element = $(this._selector[name]); + if (name === shown_name) { + element.fadeIn(300); + } else { + element.hide(); + } + } +} + +Tab.prototype.hide = function() { + var i; + + for (i = 0 ; i < this._name.length ; ++i) { + $(this._selector[this._name[i]]).fadeOut(300); + } +} + +Tab.prototype.get = function(name, sel) { + // selector concatenation + var e = $(this._selector[name] + ' ' + sel); + + return e; +} + +return { + Tab: Tab +}; + +}); diff --git a/src/client/view/timeline.js b/src/client/view/timeline.js new file mode 100644 index 00000000..0a26c0a7 --- /dev/null +++ b/src/client/view/timeline.js @@ -0,0 +1,180 @@ +"use strict" + +define(['jquery', 'rz_core'], +function ($, rz_core) { + +function checkSwitch(checkswitch) { + + if (checkswitch.checked) { + vis.selectAll(".timeline").remove(); + $('.missingdates').fadeOut(300); + scrollValue = $('body').scrollLeft(); + + $('body').scrollLeft(0); + graphstate = "GRAPH"; + rz_core.update_view__graph(); + + $('.status').fadeOut(600); + + //boxedin=false; + + } else { + timelineTimer=0; + $('.missingdates').fadeIn(300); + + graph.recenterZoom(); + + $('body').scrollLeft(scrollValue); + + graphstate = "TIMELINE"; + + rz_core.update_view__graph(); + + $('.status').fadeIn(600); + + + initAxis(); + + //boxedin=true; + } + +} + +///For some reason JQuery's $('body').scroll never worked, so I found something else. +$('body').bind('DOMMouseScroll', function(e){ + if(graphstate==="TIMELINE"){ + if(e.originalEvent.detail !== 0) { + $('.overlay').hide(); + }else{ + $('.overlay').show(); + } + }else{ + return false; + } + }); + + //IE, Opera, Safari + $('body').bind('mousewheel', function(e){ + if(graphstate==="TIMELINE"){ + if(e.originalEvent.wheelDelta !== 0) { + $('.overlay').hide(); + }else{ + $('.overlay').show(); + var left = $('body').offset().left; + $('.overlay').scrollLeft(left); + } + }else{ + return false; + } + }); + +function initAxis() { + var bar_height = 20; + var row_height = bar_height + 10; + var vertical_padding = 150 + var bar_start_offset = 40; + var h = 15 * row_height + vertical_padding; + + + var min = deliverables[0].startdate; + var max = deliverables[0].enddate; + + for (var i = 0; i < deliverables.length; i++) { + var deliv = deliverables[i]; + if (min > deliv.startdate) min = deliv.startdate; + if (end < deliv.enddate) end = deliv.enddate; + } + min = new Date(min); ///with min + max = new Date(max); + console.log("min "+min+" max "+max); + + ///update interval + var timeDiff = Math.abs(max.getTime() - min.getTime()); + var diffDays = timeDiff / (1000 * 3600 * 24); + var w = Math.round(diffDays * 15 - 100); + graphinterval = w / diffDays; + + var svg=vis; + vis.attr("width",w); + + var paddingLeft = 150; + var paddingTop = 120; + + var xScale = d3.time.scale() + .domain([min, max]).nice() + .range([paddingLeft, w]); + + var xAxis = d3.svg.axis() + .scale(xScale) + .orient("bottom"); + + // Lines + var line = svg.append("g") + .selectAll("line") + .data(xScale.ticks(40)) + .enter().append("line") + .attr("x1", xScale) + .attr("x2", xScale) + .attr("y1", paddingTop + 30) + .attr("y2", h - 50) + .attr("class", "timeline") + .style("stroke", "#ccc"); + + var y = function (i) { + return i * row_height + paddingTop + bar_start_offset; + } + + var labelY = function (i) { + return i * row_height + paddingTop + bar_start_offset + 13; + } + + // Company bars + var bar = svg.selectAll("rect") + .data(function (d) { + return Math.random() * 5; + }); + + bar.enter().append("rect") + .attr("y", -100) + .attr("x", -1000) + .attr("width", 100) + .attr("height", bar_height) + .attr("class", "company-bar timeline") + .on("mouseover", function (d) { + d3.select(this).style("fill", "#F5AF00"); + getCompanyData(String(d.uid)) + }) + .on("mouseout", function () { + d3.select(this).style("fill", "#fc0"); + }); + + var label = svg.selectAll("text") + .data(deliverables, function (key) { + return key.id + });; + + label.enter().append("text") + .attr("class", "bar-label timeline") + // .attr("text-anchor","end") + .attr("x", paddingLeft - 10) + .attr("y", function (d, i) { + return labelY(i); + }) + .text(function (d) { + }); + + // Bottom Axis + var btmAxis = svg.append("g") + .attr("transform", "translate(0," + (h - 25) + ")") + .attr("class", "axis timeline") + .call(xAxis); + + // Top Axis + var topAxis = svg.append("g") + .attr("transform", "translate(0," + paddingTop + ")") + .attr("class", "axis timeline") + .call(xAxis); +} + +return {}; +}); diff --git a/src/client/view/view.js b/src/client/view/view.js new file mode 100644 index 00000000..95bfc09b --- /dev/null +++ b/src/client/view/view.js @@ -0,0 +1,12 @@ +"use strict" + +define(['view/node_info', 'view/edge_info', 'view/internal'], +function(view_node_info, view_edge_info, view_internal) { +return { + 'node_info': view_node_info, + 'edge_info': view_edge_info, + 'hide': function() { + view_internal.edit_tab.hide(); + }, +}; +}); |
