/* This file is part of rhizi, a collaborative knowledge graph editor. Copyright (C) 2014-2015 Rhizi This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ /* view of a single graph in a graph tree. * * Usage is of a two graph system: * * * * * * Nodes are drawn per graph. * Edges connecting between the two graphs are drawn by the descendant graph. * * Updates are done using Bacon based events. * * TODO: fill in the event diagram here. * TODO: do it via tests. * * Layout is either d3 force or custom. We use custom for the temporary graph. * * This avoids the need to compute similarity between two consecutive temporary graphs, * i.e. * #alon is #bore * and * #alon is #bored * * which resulted in overly complex (read: undefined/buggy) code. */ define(['d3', 'Bacon', 'consts', 'util', 'view/selection', 'model/diff', 'view/item_info', 'view/bubble', 'model/types', 'view/layouts', 'view/filter'], function(d3 , Bacon, consts, util , selection , model_diff , item_info, view_bubble, model_types, view_layouts, view_filter) { "use strict"; // aliases var obj_take = util.obj_take; // constants (that should be moved somewhere else) var node_url_dx = 15; /* debugging helper */ function enableDebugViewOfDiffs(graph) { var debugView = document.createElement('div'); document.body.appendChild(debugView); debugView.style['pointer-events'] = 'none'; debugView.style.id = 'graphviewdiffdebug'; debugView.style.position = 'absolute'; debugView.style.left = '100px'; debugView.style.top = '100px'; debugView.innerHTML = '
debug view
'; debugView.append = function (diff) { debugView.innerHTML = debugView.innerHTML + '
' + diff + '
'; }; graph.diffBus.onValue(function (diff) { debugView.append(diff); }); } function translate(x, y) { if (x === undefined || y === undefined) { console.log('oops, undefined translate'); } return "translate(" + x + "," + y + ")"; } // "CSS" for SVG elements. Reused for editing elements. var node_text_dx = 5, node_text_dy = '.30em'; /* * Creates a new view on the given graph contained in an appended last child * to the given parent node, parent * */ function GraphView(spec) { var gv = { bubble_radius: 0, layout_animation: { interval: null, endtime: null, starttime: null, current: 30, target: 30, step_msec: 30, bubble_radius: { target: 0, start: 0, }, }, zoom_obj: spec.zoom_obj, parent_graph_zoom_obj: spec.parent_graph_zoom_obj, parent_element: parent_element, }, temporary = spec.temporary, force_enabled = !spec.temporary, parent_element = spec.parent_element, graph_name = spec.graph_name, graph = spec.graph, zoom_property = spec.zoom_property, svgInput = spec.svgInput, zoom_obj = spec.zoom_obj, zoom_obj_element = spec.zoom_obj_element, parent_graph_zoom_obj = spec.parent_graph_zoom_obj, layout_menu = $('#btn_layout'), zoomInProgress = false, layout, drag, vis, filter_states, zen_mode = false, zen_mode__auto_center = false, // FIXME - want to use parent_element w, h, cx, cy; util.assert(parent_element !== undefined && graph_name !== undefined && graph !== undefined && zoom_property !== undefined && temporary !== undefined && force_enabled !== undefined && zoom_obj !== undefined && parent_graph_zoom_obj !== undefined && zoom_obj_element !== undefined && (temporary || svgInput !== undefined), "missing spec variable"); function update_window_size() { w = $(document.body).innerWidth(); h = $(document.body).innerHeight(); cx = w / 2; cy = h / 2; } // update view whenever screen is resized $(window).asEventStream('resize').map(update_window_size).onValue(function () { update_view(false); }); update_window_size(); // read updated filter states from filters view view_filter.filter_states_bus.onValue(function (new_states) { filter_states = new_states; graph.node__set_filtered_types(filter_states); update_view(true); }); function node__pass_filter(d) { var state = filter_states && filter_states[d.type]; return temporary || state === undefined || state; } function link__pass_filter(d) { return node__pass_filter(d.__src) && node__pass_filter(d.__dst); } // TODO: nice animation FRP using Bacon combine/flatMap. // 0 // 180 10 // .. 20 // 30 // 40 // 0 30 // 20 // 10 // 0 // // // Animation target is set by stream, and initialized if not already if (spec.bubble_property) { if (temporary) { view_bubble.Bubble(parent_element[0][0], spec.bubble_property); } else { spec.bubble_property .onValue(function (r) { var now = (new Date()).getTime(); gv.layout_animation.bubble_radius.target = r; gv.layout_animation.bubble_radius.start = gv.bubble_radius; gv.layout_animation.starttime = now; gv.layout_animation.endtime = now + 300; // FIXME: use constant change? start_layout_animation(); }); } } var selection_outer_radius = 0; //200; graph.diffBus.onValue(function (diff) { var relayout = !temporary && (false === model_diff.is_attr_diff(diff)), have_position = 0, layout_x_key = graph.layout_x_key(layout.name), layout_y_key = graph.layout_y_key(layout.name), node_set_add = diff.node_set_add || [], is_full_graph_update = false; // copy position from diff based on current layout if (layout.name) { node_set_add.forEach(function (node) { if (node[layout_x_key] && node[layout_y_key]) { node.x = node[layout_x_key]; node.y = node[layout_y_key]; have_position += 1; } }); is_full_graph_update = (have_position === graph.nodes().length); if (have_position > 0) { console.log('loading layout last position from database for layout ' + layout.name); layout__load_graph(); if (is_full_graph_update) { console.log('no relayout because diff contains all graph nodes'); relayout = false; } } } if (is_full_graph_update) { layouts__set_from_nodes(node_set_add); } update_view(relayout); }); function layouts__set_from_nodes(nodes) { layouts.forEach(function (layout_) { var layout_x_key = graph.layout_x_key(layout_.name), layout_y_key = graph.layout_y_key(layout_.name); layout_.save_from_arr_id_x_y(nodes.map(function (node) { var x = node[layout_x_key], y = node[layout_y_key]; if (!x || !y) { return undefined; } return {id: node.id, x: x, y: y}; }).filter(function (datum) { return datum !== undefined; })); }); } function transformOnSelection(data) { var selection = data[0], selection_inner_radius = Math.max(30, data[1]); console.log('new selection of'); console.log(' ' + selection.root_nodes.length + ' root nodes'); console.log(' ' + selection.nodes.length + ' highlighted nodes'); console.log(' inner radius ' + selection_inner_radius); // remove existing fixed if set by us graph.nodes().forEach(function (n) { if (n.__selection) { n.x = n.px = n.__selection.x; n.y = n.py = n.__selection.y; n.fixed = n.__selection.fixed; delete n.__selection; } }); // fix position of selected nodes var count = selection.root_nodes.length, zoom_center_point = graph_to_screen(cx, cy, zoom_obj), zcx = zoom_center_point[0], zcy = zoom_center_point[1], s = zoom_obj.scale(), scaled_inner_radius = selection_inner_radius / s, scaled_outer_radius = selection_outer_radius / s, max_radius = Math.sqrt(_.max(selection.root_nodes.map( function (n) { return n.x * n.x + n.y * n.y; }))) / s; function screen_to_graph(xy) { return [zcx + xy[0], zcy + xy[1]]; } // order by angle var nodes = _.pluck(selection.root_nodes.map(function (n) { return [Math.atan2(n.y, n.x), n]; }).sort(), 1); nodes.forEach(function (n, i) { var newp; n.__selection = { fixed: n.fixed, x: n.x, y: n.y }; n.fixed = true; newp = screen_to_graph( rtheta_to_xy([(scaled_inner_radius + scaled_outer_radius) / 2, i * Math.PI * 2 / count])); n.x = n.px = newp[0]; n.y = n.py = newp[1]; }); resumeLayout(); } // on every selection change that isn't null set the selected nodes to fixed if (selection_outer_radius > 0 && !temporary) { Bacon.update( [{root_nodes: [], nodes: []}, gv.bubble_radius], [selection.selectionChangedBus], function (data, new_selection) { return [new_selection, data[1]]; }, [spec.bubble_property], function (data, radius) { return [data[0], radius]; } ).skip(1).onValue(transformOnSelection); } selection.selectionChangedBus.onValue(function () { if (zen_mode) { zen_mode__auto_center = true; layout__reset(1.0); } }); function showNodeInfo(node) { util.assert(!temporary, "cannot showNodeInfo on a temporary graph"); item_info.show(graph, node); } 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}; d.fixed = true; layout.stop(); } function dragged(d) { d.px = d.x = d3.event.x; d.py = d.y = d3.event.y; tick(); } function setupInitialPositions() { var links = graph.links(), i = 0, link; // right thing: place node opposite center of mass of existing links for (i = 0 ; i < links.length ; ++i) { link = links[i]; if ((link.__src.x === undefined || link.__src.y === undefined) && (link.__dst.x !== undefined && link.__dst.y !== undefined)) { link.__src.x = link.__dst.x; link.__src.y = link.__dst.y; } if ((link.__dst.x === undefined || link.__dst.y === undefined) && (link.__src.x !== undefined && link.__dst.y !== undefined)) { link.__dst.x = link.__src.x; link.__dst.y = link.__src.y; } // TODO: collect links that are totally disconnected. here the default d3 force // placement (w/h) makes some sense. } // TODO: node placement by default - middle of screen? } function resumeLayout() { setupInitialPositions(); layout.alpha(0.05); } 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) { d.fixed = true; d.px = d.x; d.py = d.y; tick(); if (layout.name === 'custom') { graph.nodes__store_layout_positions(layout.name, [d.id]); } } } var urlValid = function(url) { return url !== undefined && url !== null && url.length > 0 && url.slice(0, 4) === 'http'; }; var urlImage = function(url) { return urlValid(url); }; function nodes__filtered() { return graph.nodes().filter(node__pass_filter); } function nodes__visible() { return zen_mode ? selection.related_nodes() : nodes__filtered(); } function links__filtered() { return graph.links().filter(link__pass_filter); } function links__visible() { return zen_mode ? graph.find_links__by_nodes(selection.related_nodes()) : links__filtered(); } function node__transform(d) { return translate(d.bx, d.by); } function update_view(relayout) { var node, link, link_g, link_text, circle, unselected_selector = '#' + graph_name + ' #link-group', selected_selector = '#' + graph_name + ' #selected-link-group', unselected_link_group = document.querySelector(unselected_selector), selected_link_group = document.querySelector(selected_selector), visible_nodes = nodes__visible(), visible_links = links__visible(); function set_data_by_id(d3e, data) { return d3e.data(data, function (d) { return d.id; }); } var node__text_x = function(d) { return node_text_dx + node__radius(d) + (urlValid(d.url) ? node_url_dx : 0); }; function model_id_from_dom_id(dom_id) { return dom_id.split('__')[0]; } function node_text_setup() { var node_text; node_text = set_data_by_id(vis.selectAll("g.nodetext"), visible_nodes); node_text.enter().insert('g') .attr('id', function (d) { return text_node_id(d.id); }) .attr("class", "nodetext graph") .on("click", function(d, i) { if (d3.event.defaultPrevented) { // drag happened, ignore click https://github.com/mbostock/d3/wiki/Drag-Behavior#on return; } if (!temporary) { svgInput.enable(this.querySelector('text'), d, node__text_x(d)); (d3.event.shiftKey ? selection.invert_nodes : selection.select_nodes)([d]); showNodeInfo(graph.find_node__by_id(model_id_from_dom_id(this.id))); } d3.event.stopPropagation(); }) .insert("text") .attr("dy", node_text_dy); node_text.exit().remove(); var nodeText = function(d) { var selected = selection.node_related(d); if (!d.name) { return "_"; } if (temporary || selected) { return d.name; } else { if (d.name.length < 28) { return d.name; } else { return d.name.substring(0, 25) + "..."; } } }; node_text.select('text').text(nodeText) .attr("dx", node__text_x); node_text.attr('class', function(d) { return ['nodetext graph', selection.class__node(d, temporary)].join(' '); }); } /* * @this - the element being hovered on. Could use d.id instead */ var link__hover__start = function (d) { var e = document.getElementById(d.id); add_class(e, 'hovering'); // show text if not selected if (!selection.link_related(d)) { set_link_label_text(d.id, link_text__short(d)); } }; gv.link__hover__start = link__hover__start; var link__hover__end = function (d) { var e = document.getElementById(d.id); remove_class(e, 'hovering'); // hide text if not selected if (!selection.link_related(d)) { set_link_label_text(d.id, ""); } }; gv.link__hover__end = link__hover__end; var link_on_hover = function (d) { $('#' + d.id).hover(function (e) { link__hover__start(d); }, function (e) { link__hover__end(d); }); }; gv.node__hover__start = function (d) { var e = document.getElementById(d.id); add_class(e, 'hovering'); }; gv.node__hover__end = function (d) { var e = document.getElementById(d.id); remove_class(e, 'hovering'); }; relayout = (relayout === undefined && true) || relayout; link = set_data_by_id(d3.select(unselected_link_group).selectAll("g.link"), visible_links); // link group - container for paths. Created after text so it is above (see z-order for SVG 1.1) 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"); // add link label third link_g .append("text") .attr('id', function(d){ return text_link_id(d.id); }) // append link id to enable data->visual mapping .attr("text-anchor", "middle") .attr("class", "linklabel graph") .on("click", function(d) { if (!temporary) { // FIXME: if temporary don't even put a click handler svgInput.enable(this, d); } }); function text_link_id(id) { return id + '__link_text'; } function text_node_id(id) { return id + '__node_text'; } function visit_one_down(base, visitor) { visitor(base); _.forEach(base.childNodes, visitor); } function add_class(base, clazz) { visit_one_down(base, function (e) { e.classList && e.classList.add(clazz); }); } function remove_class(base, clazz) { visit_one_down(base, function (e) { e.classList && e.classList.remove(clazz); }); } function set_link_label_text(link_id, text) { $('#' + text_link_id(link_id)).text(text); } link_g.each(function (d) { link_on_hover(d); }); link_g.on("click", function(d) { if (zoomInProgress) { // don't disable zoomInProgress, it will be disabled by the svg_click_handler // after this events bubbles to the svg element return; } item_info.show(graph, d, ['name']); (d3.event.shiftKey? selection.invert_link : selection.select_link)(this.link); }); //var selected_N = selection: link.attr("class", function(d){ var temp_and = (d.name && d.name.replace(/ /g,"") === "and" && temporary) ? "temp_and" : ""; return ["graph link", temp_and, selection.class__link(d, temporary)].join(' '); }); link.selectAll('path.link') .attr('class', function(d) { return [d.state || "perm", selection.class__link(d, temporary), "link graph"].join(' '); }); link.exit().remove(); set_data_by_id(vis.selectAll('g.link'), visible_links) .each(function (d) { this.link = d; }); function link_text__short(d) { var name = d.name || ""; if (temporary || name.length < consts.link_text_short_length + 3) { return name; } return name.substring(0, consts.link_text_short_length) + "..."; } link_text = set_data_by_id(vis.selectAll(".linklabel"), visible_links); link_text .text(function(d) { var related = selection.link_related(d); if (!temporary && !related) { return ""; } if (related) { return d.name; } return link_text__short(d); }) .attr("class", function(d) { return ["linklabel graph", selection.class__link(d, temporary)].join(' '); }); link_text.exit().remove(); node = set_data_by_id(vis.selectAll(".node"), visible_nodes); node_text_setup(); var nodeEnter = node.enter() .append("g") .attr('id', function(d) { return d.id; }) // append node id to enable data->visual mapping .call(drag); node.attr('class', function(d) { return ['node' +" " + d.type + " " + d.state + " " + "graph", selection.class__node(d, temporary)].join(' '); }) .each(function (d) { d.zoom_obj = zoom_obj; // FIXME new object NodeView pointing to Node and Zoom d.width = 900; d.height = 900; }); function load_image(element, image_url) { var image = new Image(); image.onload = function () { var aspect = this.height / this.width, width = Math.min(100, this.width); element.setAttribute("width", width); element.setAttribute("height", Math.min(width * aspect, this.height)); element.setAttributeNS("http://www.w3.org/1999/xlink", "href", this.src); }; image.src = image_url; } var noderef = nodeEnter.insert('a') .attr("class", "nodeurl graph") .attr("dy", node_text_dy); noderef.insert("image"); node.select('g.node a') .attr("xlink:href", function (d) { return d.url; }) .attr("xlink:title", function (d) { return d.url; }) .attr("target", "_blank") .attr("visibility", function(d) { return urlValid(d.url) ? "visible" : "hidden"; }) .attr("pointer-events", function(d) { return urlValid(d.url) ? "all" : "none"; }) .attr("transform", function (d) { return urlValid(d['image-url']) ? "translate(22,-7)" : "translate(10, -7)"; }); node.select('g.node > a > image') .each(function () { load_image(this, "/static/img/url-icon.png"); }); function node__click_handler(d, _i) { if (d3.event.defaultPrevented) { // drag happened, ignore click https://github.com/mbostock/d3/wiki/Drag-Behavior#on return; } d3.event.stopPropagation(); (d3.event.shiftKey ? selection.invert_nodes : selection.select_nodes)([d]); if(!temporary) { showNodeInfo(d); } } function node__radius (d) { return urlValid(d['image-url']) ? 20 : 10; } function filter_id(id) { return id + '__node_filter'; } function feimage_id(id) { return id + '__node_filter_feimage'; } function clip_path_id(id) { return id + '__node_clip_path'; } function svg_url(id) { return 'url(#' + id + ')'; } var filter_group = d3.select('.nodefilter-group').selectAll(".nodefilter") .data(visible_nodes, function (d) { return d.id; }); var filter = filter_group.enter() .insert('filter') .attr('class', 'nodefilter') .attr('id', function(d) { return filter_id(d.id); }) .attr('x', '0%') .attr('y', '0%') .attr('width', '100%') .attr('height', '100%') .insert('feImage') .attr('class', 'nodefilter_feimage') .attr('id', function (d) { return feimage_id(d.id); }); d3.selectAll('.nodefilter_feimage') .data(visible_nodes, function (d) { return d.id; }) .attr('xlink:href', function (d) { return d['image-url']; }); if (!temporary) { // FIXME: defs per graph filter_group.exit().remove(); } var clip_path = nodeEnter .append('clipPath') .attr('class', 'node_clippath') .attr('id', function (d) { return clip_path_id(d.id); }) .append('circle') .attr('cx', 0) .attr('cy', 0); d3.selectAll('.node_clippath circle') .data(visible_nodes, function (d) { return d.id; }) .attr('r', node__radius); circle = nodeEnter.insert("circle"); node.select('g.node > circle') .attr("r", node__radius) .attr("filter", function (d) { return urlImage(d['image-url']) ? svg_url(filter_id(d.id)) : ''; }) .attr("clip-path", function (d) { return urlImage(d['image-url']) ? svg_url(clip_path_id(d.id)) : ''; }) .on("click", node__click_handler); 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 "static/img/check.png"; case "current": return "static/img/wait.png"; case "waiting": return "static/img/cross.png"; } }); node.exit().remove(); if (force_enabled) { if (!temporary) { layout__load_graph(); } if (relayout) { layout__reset(0.01); } else { layout.start().alpha(0.0001); tick(); // this will be called before the end event is triggered by layout completing. } } else { start_layout_animation(); } } function segment_in_segment(inner_low, inner_high, outer_low, outer_high) { return (inner_low >= outer_low && inner_low < outer_high && inner_high >= outer_low && inner_high < outer_high); } /** * For a single dimention return [scale, translate] * * If screen_low < rect_low, rect_high < screen_high * returns [1, 0] * else returns scale and translation that will put rect in percent of screen. * * currently moves the selection to the center of the screen. The bubble effect * will take care it won't overlap the new (temp) nodes. * * XXX: move the minimal amount in the direction from which it is coming? */ function scale_and_move(screen_low, screen_high, rect_low, rect_high, percent, current_scale, current_translate) { function forward(x) { return x * current_scale + current_translate; }; var scaled_low = forward(rect_low), scaled_high = forward(rect_high), in_view = segment_in_segment(scaled_low, scaled_high, screen_low, screen_high), new_scale; new_scale = (screen_high - screen_low) / (rect_high - rect_low) * percent; new_scale = Math.min(3, Math.max(0.1, new_scale)); return [ in_view , // scale to percent of screen new_scale , // translate middle to middle - function because scale not determined yet function (scale) { return ((screen_low + screen_high) / 2 - (rect_high + rect_low) / 2 * scale); } ]; } function nodes__user_visible(nodes, zoom_if_visible, duration) { if (nodes.length == 0) { return; } var ratio_used = 0.8, xs = nodes.map(obj_take('x')), ys = nodes.map(obj_take('y')), x_min = Math.min.apply(null, xs), x_max = Math.max.apply(null, xs), y_min = Math.min.apply(null, ys), y_max = Math.max.apply(null, ys), current_scale = zoom_obj.scale(), current_translate = zoom_obj.translate(), screen_width = $(document.body).innerWidth(), screen_height = $(document.body).innerHeight(), x_data = scale_and_move(0, screen_width, x_min, x_max, ratio_used, current_scale, current_translate[0]), x_in_view = x_data[0], x_scale = x_data[1], x_translate_fn = x_data[2], y_data = scale_and_move(0, screen_height, y_min, y_max, ratio_used, current_scale, current_translate[1]), y_in_view = y_data[0], y_scale = y_data[1], y_translate_fn = y_data[2], min_scale = Math.min(x_scale, y_scale), x_translate = x_translate_fn(min_scale), y_translate = y_translate_fn(min_scale); if (zoom_if_visible || (!x_in_view || !y_in_view)) { set_scale_translate(min_scale, [x_translate, y_translate], duration); } }; gv.nodes__user_visible = nodes__user_visible; var set_scale_translate = function(scale, translate, duration) { var current_scale = zoom_obj.scale(), current_translate = zoom_obj.translate(); duration = duration || 0; if (scale === current_scale && translate[0] === current_translate[0] && translate[0] === current_translate[1]) { return; } zoom_obj.translate([translate[0], translate[1]]); zoom_obj.scale(scale); zoom_obj.event(zoom_obj_element.transition().duration(duration)); }; gv.__set_scale_translate = set_scale_translate; var scale__absolute = function (new_scale) { var t = zoom_obj.translate(), s = zoom_obj.scale(), ds = new_scale - s, w = window.innerWidth, h = window.innerHeight; set_scale_translate(new_scale, [t[0] - ds * w / 2, t[1] - ds * h / 2], 200); }; gv.scale__absolute = scale__absolute; gv.update_view = update_view; function start_layout_animation() { var on_interval = function() { var now = (new Date()).getTime(), end_now = gv.layout_animation.endtime - now, b_dict = gv.layout_animation.bubble_radius, end_start = gv.layout_animation.endtime - gv.layout_animation.starttime, now_start = now - gv.layout_animation.starttime, d_current = gv.layout_animation.target - gv.layout_animation.current, d_bubble_radius = b_dict.target - gv.bubble_radius, step_msec = gv.layout_animation.step_msec; util.assert(b_dict.target !== undefined, "bubble radius target is undefined"); // we loop some just to settle the temporary graph animation if (d_current == 0 && d_bubble_radius == 0) { clearInterval(gv.layout_animation.interval); gv.layout_animation.interval = null; } else { if (d_current > 0) { gv.layout_animation.current += 1; } if (d_bubble_radius != 0) { if (end_now <= 0) { gv.bubble_radius = b_dict.target; } else { gv.bubble_radius = b_dict.start + (b_dict.target - b_dict.start) * (now_start / end_start); } } } util.assert(gv.bubble_radius !== undefined, "bug"); tick(); }; if (gv.layout_animation.interval == null) { gv.layout_animation.interval = setInterval(on_interval, gv.layout_animation.step_msec); } if (temporary) { gv.layout_animation.current = 0; } on_interval(); } function check_for_nan(x) { if (Number.isNaN(x)) { console.log('nan problem'); layout.stop(); } return Number.isNaN(x); } var newnodes=1; function circle__set_positions() { //circles animation var tempcounter = 0, temptotal = graph.nodes().filter(function(d){ return temporary && d.type !== "chainlink"; }).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; tempcounter++; r = 60 + newnodes * 20; a = -Math.PI + Math.PI * 2 * (tempcounter - 1) / newnodes + 0.3; d.x = cx + r * Math.cos(a); d.y = cy + r * Math.sin(a); check_for_nan(d.x); check_for_nan(d.y); }); } /** * Transform according to the parent transform * @param d = {x:x, y:y} * @return {x:new_x, y:new_y} */ function apply_node_zoom_obj(d) { return apply_zoom_obj(d.bx, d.by, d.zoom_obj); } function apply_zoom_obj(x, y, zoom_obj) { var zoom_translate = zoom_obj.translate(), zx = zoom_translate[0], zy = zoom_translate[1], s = zoom_obj.scale(); return [x * s + zx, y * s + zy]; } function graph_to_screen(x, y, zoom_obj) { var zoom_translate = zoom_obj.translate(), zx = zoom_translate[0], zy = zoom_translate[1], s = zoom_obj.scale(); return [(x - zx) / s, (y - zy) / s]; } function xy_to_rtheta(xy) { var x = xy[0], y = xy[1]; return [Math.sqrt(x * x + y * y), Math.atan2(y, x)]; } function rtheta_to_xy(rtheta) { var r = rtheta[0], theta = rtheta[1]; return [r * Math.cos(theta), r * Math.sin(theta)]; } /** * angle preserving transform. * * TODO: split back to zoom transform and ring transform * * In the range axis the transform is: * [0, bubble_radius * 2] => [bubble_radius, bubble_radius * 2] linearly * [bubble_radius * 2, inf] => identify */ function bubble_transform(d, bubble_radius) { if (bubble_radius === 0) { return d; } var zoom_center_point = graph_to_screen(cx, cy, zoom_obj), zcx = zoom_center_point[0], zcy = zoom_center_point[1], dx = d.x - zcx, dy = d.y - zcy, r = Math.sqrt(dx * dx + dy * dy), a = Math.atan2(dy, dx), scale = zoom_obj.scale(), scaled_bubble_radius = bubble_radius / scale, new_r = r > scaled_bubble_radius * 2 ? r : r / 2 + scaled_bubble_radius; // FIXME: r == 0 (or close enough) return {x: zcx + new_r * Math.cos(a), y: zcy + new_r * Math.sin(a)}; } function ring_transform(xy, inner_radius, outer_radius, max_radius) { var rtheta = xy_to_rtheta(xy), r = rtheta[0], theta = rtheta[1], dr = outer_radius - inner_radius, //rout = Math.max(inner_radius, Math.min(outer_radius, r)); rout = dr * r / max_radius + inner_radius; //console.log([inner_radius, outer_radius, max_radius, r, rout]); return rtheta_to_xy([rout, rtheta[1]]); } function handle_space(e, node) { (e.shiftKey? selection.invert_nodes : selection.select_nodes)([node]); } function handle_enter(e, node) { showNodeInfo(node); } var keyboard_handlers = _.object([ [consts.VK_SPACE, handle_space], [consts.VK_ENTER, handle_enter], ]); function keyboard_handler(e) { var target_node = graph.find_node__by_id(e.target.id); if (null === target_node || undefined === keyboard_handlers[e.keyCode]) { return; } keyboard_handlers[e.keyCode](e, target_node); e.preventDefault(); e.stopPropagation(); } gv.keyboard_handler = keyboard_handler; /** * Recomputes all link and node locations according to force layout and * bubble animation. */ function tick(e) { var node = vis.selectAll(".node"), link = vis.selectAll("path.link"), link_text = vis.selectAll(".linklabel"), node_text = vis.selectAll("g.nodetext"), bubble_radius = temporary ? 0 : gv.bubble_radius; if (temporary) { // XXX convert to a layout circle__set_positions(); } // XXX This computes every node ignoring filter. node.each(function (d) { if (check_for_nan(d.x) || check_for_nan(d.y)) { return; } if (d.x === undefined || d.y === undefined) { console.log('oops, undefined position'); } var d2 = d.__selection !== undefined ? d : bubble_transform(d, bubble_radius); d.bx = d2.x; d.by = d2.y; }); // transform nodes first to record bubble x & y (bx & by) node.attr("transform", node__transform); node_text.attr("transform", node__transform); function same_zoom(d) { if (d.zoom_obj === null || parent_graph_zoom_obj === null) { return [d.bx, d.by]; } else { return apply_node_zoom_obj(d); } } link.attr("d", function(d, i) { var d_val, ghost; util.assert(d.__src && d.__dst && d.__src.x !== undefined && d.__src.y !== undefined && d.__dst.x !== undefined && d.__dst.y !== undefined, "missing src and dst points"); var src = same_zoom(d.__src), dst = same_zoom(d.__dst); d_val = "M" + src[0] + "," + src[1] + "L" + dst[0] + "," + dst[1]; // update ghostlink position ghost = $(this.nextElementSibling); ghost.attr("d", d_val); return d_val; }); link_text.attr("transform", function(d) { var src = same_zoom(d.__src), dst = same_zoom(d.__dst); return "translate(" + (src[0] + dst[0]) / 2 + "," + (src[1] + dst[1]) / 2 + ")"; }); // tabindex affects focus change on tab key. // Sort top to bottom left to right node.sort(function (a, b) { return b.py === a.py ? (b.px === a.px ? 0 : (b.px > a.px ? -1 : 1)) : (b.py > a.py ? -1 : 1); }) .attr('tabindex', function(d, i) { return i + 100; }); } // 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 = parent_element.append("g"); vis.attr("id", graph_name); vis.append("g").attr("id", "link-group"); vis.append("g").attr("id", "selected-link-group"); gv.root_element = vis[0][0]; drag = d3.behavior.drag() .origin(function(d) { return d; }) .on("dragstart", dragstarted) .on("drag", dragged) .on("dragend", dragended); var redrawBus = new Bacon.Bus(); redrawBus.onValue(tick); var pushRedraw = function() { redrawBus.push(null); }; if (spec.parent_graph_zoom_obj) { var existing_zoom_cb = spec.parent_graph_zoom_obj.on('zoom'); spec.parent_graph_zoom_obj.on('zoom', function () { existing_zoom_cb.apply(null, arguments); pushRedraw(null); }); } function record_position_to_database() { graph.nodes__store_layout_positions(layout.name); } var layouts = view_layouts.layouts.map(function (layout_data) { return layout_data.create(graph); }); function set_layout_toolbar() { layout_menu.on('click', function() { var layout_btns = layout_menu.find('.btn_layout'); if (layout_btns.length > 0) { // menu open layout_btns.remove(); return; } view_layouts.layouts.forEach(function (layout_data, i) { var button_layout = layouts[i], button = $('
'); button.html(layout_data.name); button.addClass(layout_data.clazz); button.addClass('btn_layout'); button.on('click', function () { set_layout(button_layout); layout_btns.remove(); }); layout_menu.append(button); }); }); } function layout__tick__callback() { if (zen_mode && zen_mode__auto_center) { center_on_selection_related(); } pushRedraw(); } function layout__reset(alpha) { alpha = alpha || 0.01; layout.nodes_links(nodes__visible(), links__visible()) .alpha(alpha) .start(); } function layout__load_graph() { layout.nodes_links(nodes__visible(), links__visible()); } function set_layout(new_layout) { if (layout !== undefined) { layout.save(); layout.stop(); } // remove fixed status, the fixed status is restored from the layout graph.nodes().forEach(function (node) { node.fixed = undefined; }); layout = new_layout .size([w, h]) .on("tick", layout__tick__callback) .on("end", record_position_to_database) .nodes_links(nodes__visible(), links__visible()) .restore() .zen_mode(zen_mode) .start() .alpha(0.01); gv.layout = layout; } set_layout(temporary ? view_layouts.empty(graph) : layouts[0]); if (!temporary) { set_layout_toolbar(layout_menu); gv.hide_layout_menu = function () { layout_menu.find('.btn_layout').remove(); }; } gv.dev_set_layout = function (layout_func) { set_layout(function () { return layouts.layout__sync(layout_func); }); }; zoom_property.onValue(function (val) { zoomInProgress = val; tick(); }); function disable_zen_mode_auto_center() { zen_mode__auto_center = false; } // [!] hack, should take control of these event listeners and feed them to the zoom behaviour parent_element[0][0].parentElement.addEventListener('wheel', disable_zen_mode_auto_center); parent_element[0][0].parentElement.addEventListener('mousedown', disable_zen_mode_auto_center); function center_on_selection_related() { nodes__user_visible(selection.related_nodes(), true, 100 /* ms, duration of animation */); } gv.link__pass_filter = link__pass_filter; gv.node__pass_filter = node__pass_filter; function zen_mode__set(value) { if (zen_mode === value) { return; } zen_mode = value; layout.nodes_links(nodes__visible(), links__visible()); layout.zen_mode(value); update_view(true); zen_mode__auto_center = zen_mode; } gv.zen_mode__set = zen_mode__set; gv.zen_mode__toggle = function () { zen_mode__set(!zen_mode); }; gv.zen_mode__cancel = function () { zen_mode__set(false); }; gv.layouts = layouts; return gv; } return { GraphView: GraphView, }; });