jQuery = require "jquery" $ = jQuery d3 = require "d3" d3tip = require "d3-tip" d3tip d3 global.gdn = require "./git-deps-noty.coffee" global.gdd = require "./git-deps-data.coffee" global.gdl = require "./git-deps-layout.coffee" fullScreen = require "./fullscreen" SVG_MARGIN = 2 # space around , matching #svg-container border RECT_MARGIN = 14 # space in between PADDING = 5 # space in between label and border EDGE_ROUTING_MARGIN = 3 PLUS_ICON_WIDTH = 14 svg_width = 960 svg_height = 800 old_svg_height = undefined old_svg_width = undefined color = d3.scale.category20() global.d3cola = cola.d3adaptor() d3cola .flowLayout("y", 150) .avoidOverlaps(true) #.linkDistance(60) #.symmetricDiffLinkLengths(30) #.jaccardLinkLengths(100) # d3 visualization elements container = undefined svg = undefined fg = undefined nodes = undefined paths = undefined tip = undefined tip_template = undefined zoom = undefined options = undefined # Options will be retrieved from web server jQuery -> d3.json "options", (error, data) -> options = data d3.html "tip-template.html", (error, html) -> tip_template = html #setup_default_form_values(); $("form.commitish").submit (event) -> event.preventDefault() add_commitish $(".commitish input").val() init_svg() setup_default_form_values = -> $("input[type=text]").each(-> $(this).val $(this).attr("defaultValue") $(this).css color: "grey" ).focus(-> if $(this).val() is $(this).attr("defaultValue") $(this).val "" $(this).css color: "black" ).blur -> if $(this).val() is "" $(this).val $(this).attr("defaultValue") $(this).css color: "grey" resize_window = -> calculate_svg_size_from_container() fit_svg_to_container() redraw true redraw = (transition) -> # if mouse down then we are dragging not panning # if nodeMouseDown # return ((if transition then fg.transition() else fg)) .attr "transform", "translate(#{zoom.translate()}) scale(#{zoom.scale()})" graph_bounds = -> x = Number.POSITIVE_INFINITY X = Number.NEGATIVE_INFINITY y = Number.POSITIVE_INFINITY Y = Number.NEGATIVE_INFINITY fg.selectAll(".node").each (d) -> x = Math.min(x, d.x - d.width / 2) y = Math.min(y, d.y - d.height / 2) X = Math.max(X, d.x + d.width / 2) Y = Math.max(Y, d.y + d.height / 2) return {} = x: x X: X y: y Y: Y fit_svg_to_container = -> svg.attr("width", svg_width).attr("height", svg_height) full_screen_cancel = -> svg_width = old_svg_width svg_height = old_svg_height fit_svg_to_container() #zoom_to_fit(); resize_window() full_screen_click = -> fullScreen container.node(), full_screen_cancel fit_svg_to_container() resize_window() #zoom_to_fit(); return false zoom_to_fit = -> b = graph_bounds() w = b.X - b.x h = b.Y - b.y cw = svg.attr("width") ch = svg.attr("height") s = Math.min(cw / w, ch / h) tx = -b.x * s + (cw / s - w) * s / 2 ty = -b.y * s + (ch / s - h) * s / 2 zoom.translate([tx, ty]).scale s redraw true return false window.full_screen_click = full_screen_click window.zoom_to_fit = zoom_to_fit add_commitish = (commitish) -> tip.hide() if tip? draw_graph commitish calculate_svg_size_from_container = -> old_svg_width = svg_width old_svg_height = svg_height svg_width = container.node().offsetWidth - SVG_MARGIN svg_height = container.node().offsetHeight - SVG_MARGIN init_svg = -> container = d3.select("#svg-container") calculate_svg_size_from_container() svg = container.append("svg") .attr("width", svg_width) .attr("height", svg_height) d3cola.size [svg_width, svg_height] d3.select(window).on "resize", resize_window zoom = d3.behavior.zoom() svg.append("rect") .attr("class", "background") .attr("width", "100%") .attr("height", "100%") .call(zoom.on("zoom", redraw)) .on("dblclick.zoom", zoom_to_fit) fg = svg.append("g") svg_defs fg update_cola = -> gdl.build_constraints() d3cola .nodes(gdd.nodes) .links(gdd.links) .constraints(gdl.constraints) draw_graph = (commitish) -> d3.json "deps.json/" + commitish, (error, data) -> if error details = JSON.parse(error.responseText) gdn.error details.message return new_data = gdd.add(data) unless new_data gdn.warn "No new commits or dependencies found!" update_rect_explored() return new_data_notification new_data focus_commitish_input() update_cola() paths = fg.selectAll(".link") .data(gdd.links, link_key) paths.enter().append("svg:path") .attr("class", "link") nodes = fg.selectAll(".node") .data(gdd.nodes, (d) -> d.sha1) global.nodes = nodes g_enter = nodes.enter().append("g") .attr("class", "node") # Failed attempt to use dagre layout as starting positions # https://github.com/tgdwyer/WebCola/issues/63 # .each((d, i) -> # n = gdl.node d.sha1 # d.x = n.x # d.y = n.y # N.B. has to be done on the update selection, i.e. *after* the enter! nodes.call(d3cola.drag) init_tip() unless tip? # Event handlers need to be updated every time new nodes are added. init_tip_event_handlers(nodes) [rects, labels] = draw_new_nodes fg, g_enter position_nodes(rects, labels) update_rect_explored() focus_commitish_input = () -> d3.select('.commitish input').node().focus() # Required for object constancy: http://bost.ocks.org/mike/constancy/ ... link_key = (link) -> source = sha1_of_link_pointer(link.source) target = sha1_of_link_pointer(link.target) return source + " " + target # ... but even though link sources and targets are initially fed in # as indices into the nodes array, webcola then replaces the indices # with references to the node objects. So we have to deal with both # cases when ensuring we are uniquely identifying each link. sha1_of_link_pointer = (pointer) -> return pointer.sha1 if typeof (pointer) is "object" return gdd.nodes[pointer].sha1 init_tip = () -> tip = d3.tip().attr("class", "d3-tip").html(tip_html) global.tip = tip fg.call tip # This hack is to work around something which looks like a bug in d3 # or d3-tip. tip.show is defined as: # # function() { # var args = Array.prototype.slice.call(arguments) # if(args[args.length - 1] instanceof SVGElement) target = args.pop() # ... # # and there's also: # # function getScreenBBox() { # var targetel = target || d3.event.target; # ... # # which I'm guessing normally uses d3.event.target. However for some # reason when using tip.show as the dragend handler, d3.event.target # points to a function rather than the expected DOM element, which # appears to be exactly the same problem described here: # # http://stackoverflow.com/questions/12934731/d3-event-targets # # However I tried rects.call ... instead of nodes.call as suggested in # that SO article, but it resulted in the callback not being triggered # at all. By *always* providing the exact SVGElement the tip is # supposed to target, the desired behaviour is obtained. If tip_show # is only used in tip_dragend_handler then the target gets memoised, # and a normal hover-based tip.show shows the target last shown by a # drag, rather than the node being hovered over. Weird, and annoying. tip_show = (d, i) -> tip.show d, i, nodes[0][i] tip_dragend_handler = (d, i, elt) -> focus_commitish_input() tip_show d, i init_tip_event_handlers = (selection) -> # We have to reuse the same drag object, otherwise only one # of the event handlers will work. drag = d3cola.drag() hide_tip_on_drag = drag.on("drag", tip.hide) on_dragend = drag.on("dragend", tip_dragend_handler) selection.call hide_tip_on_drag selection.call on_dragend draw_new_nodes = (fg, g_enter) -> rects = g_enter.append('rect') .attr('rx', 5) .attr('ry', 5) .on('dblclick', (d) -> launch_viewer d) labels = g_enter.append('text').text((d) -> d.name ).each((d) -> b = @getBBox() # Calculate width/height of rectangle from text bounding box. d.rect_width = b.width + 2 * PADDING d.rect_height = b.height + 2 * PADDING # Now set the node width/height as used by cola for # positioning. This has to include the margin # outside the rectangle. d.width = d.rect_width + 2 * RECT_MARGIN d.height = d.rect_height + 2 * RECT_MARGIN ) return [rects, labels] explore_node = (d) -> if d.explored gdn.warn "Commit #{d.name} already explored" else add_commitish d.sha1 launch_viewer = (d) -> window.location.assign "gitfile://#{options.repo_path}##{d.sha1}" new_data_notification = (new_data) -> new_nodes = new_data[0] new_deps = new_data[1] root = new_data[2] notification = if root.commitish == root.sha1 "Analysed dependencies of #{root.abbrev}" else "#{root.commitish} resolved as #{root.sha1}" notification += "

#{new_nodes} new commit" notification += "s" unless new_nodes == 1 notification += "; #{new_deps} new " + (if new_deps == 1 then "dependency" else "dependencies") notification += "

" gdn.success notification svg_defs = () -> # define arrow markers for graph links defs = svg.insert("svg:defs") defs.append("svg:marker") .attr("id", "end-arrow") .attr("viewBox", "0 -5 10 10") .attr("refX", 6) .attr("markerWidth", 8) .attr("markerHeight", 8) .attr("orient", "auto") .append("svg:path") .attr("d", "M0,-5L10,0L0,5") .attr("fill", "#000") plus_icon = defs.append("svg:symbol") .attr("id", "plus-icon") .attr("viewBox", "-51 -51 102 102") # allow for stroke-width 1 # border plus_icon.append("svg:rect") .attr("width", 100) .attr("height", 100) .attr("fill", "#295b8c") .attr("stroke", "rgb(106, 136, 200)") .attr("x", -50) .attr("y", -50) .attr("rx", 20) .attr("ry", 20) # plus sign plus_icon.append("svg:path") .attr("d", "M-30,0 H30 M0,-30 V30") .attr("stroke", "white") .attr("stroke-width", 10) .attr("stroke-linecap", "round") # Uncomment to see a large version: # fg.append("use") # .attr("class", "plus-icon") # .attr("xlink:href", "#plus-icon") # .attr("width", "200") # .attr("height", "200") # .attr("x", 400) # .attr("y", 200) position_nodes = (rects, labels) -> rects .attr("width", (d, i) -> d.rect_width) .attr("height", (d, i) -> d.rect_height) .on("mouseover", tip_show) .on("mouseout", tip.hide) # Centre labels labels .attr("x", (d) -> d.rect_width / 2) .attr("y", (d) -> d.rect_height / 2) .on("mouseover", tip_show) .on("mouseout", tip.hide) d3cola.start 10, 20, 20 d3cola.on "tick", tick_handler # d3cola.on "end", routeEdges # turn on overlap avoidance after first convergence # d3cola.on("end", () -> # unless d3cola.avoidOverlaps # gdd.nodes.forEach((v) -> # v.width = v.height = 10 # d3cola.avoidOverlaps true # d3cola.start update_rect_explored = () -> d3.selectAll(".node rect").attr "class", (d) -> if d.explored then "explored" else "unexplored" nodes.each (d) -> existing_icon = d3.select(this).select("use.plus-icon") if d.explored existing_icon.remove() else if existing_icon.empty() add_plus_icon this add_plus_icon = (node_element) -> n = d3.select(node_element) rw = node_element.__data__.rect_width rh = node_element.__data__.rect_height icon = n.insert('use') .attr('class', 'plus-icon') .attr('xlink:href', '#plus-icon') .attr('x', rw/2) .attr('y', rh - PLUS_ICON_WIDTH/2) .attr('width', 0) .attr('height', 0) icon .on('mouseover', (d, i) -> icon_ease_in icon, rw) .on('mouseout', (d, i) -> icon_ease_out icon, rw) .on('click', (d) -> explore_node d) n .on('mouseover', (d, i) -> icon_ease_in icon, rw) .on('mouseout', (d, i) -> icon_ease_out icon, rw) icon_ease_in = (icon, rw) -> icon.transition() .ease('cubic-out') .duration(200) .attr('width', PLUS_ICON_WIDTH) .attr('height', PLUS_ICON_WIDTH) .attr('x', rw/2 - PLUS_ICON_WIDTH/2) icon_ease_out = (icon, rw) -> icon.transition() .attr(rw/2 - PLUS_ICON_WIDTH/2) .ease('cubic-out') .duration(200) .attr('width', 0) .attr('height', 0) .attr('x', rw/2) tip_html = (d) -> fragment = $(tip_template).clone() top = fragment.find("#fragment") title = top.find("p.commit-title") title.text d.title if d.refs title.append " " refs = title.children().first() refs.addClass("commit-describe commit-ref") .text(d.refs.join(" ")) top.find("span.commit-author").text(d.author_name) date = new Date(d.author_time * 1000) top.find("time.commit-time") .attr("datetime", date.toISOString()) .text(date) pre = top.find(".commit-body pre").text(d.body) if options.debug # deps = gdd.deps[d.sha1] # if deps # sha1s = [gdd.node(sha1).name for name, bool of deps] # top.append("
Dependencies: " + sha1s.join(", ")); index = gdd.node_index[d.sha1] debug = "
node index: " + index dagre_node = gdl.graph.node(d.sha1) debug += "
dagre: (#{dagre_node.x}, #{dagre_node.y})" top.append debug # Javascript *sucks*. There's no way to get the outerHTML of a # document fragment, so you have to wrap the whole thing in a # single parent and then look that up via children[0]. return fragment[0].children[0].outerHTML translate = (x, y) -> "translate(#{x},#{y})" tick_handler = -> nodes.each (d) -> # cola sets the bounds property which is a Rectangle # representing the space which other nodes should not # overlap. The innerBounds property seems to tell # cola the Rectangle which is the visible part of the # node, minus any blank margin. d.innerBounds = d.bounds.inflate(-RECT_MARGIN) nodes.attr "transform", (d) -> translate d.innerBounds.x, d.innerBounds.y paths.each (d) -> @parentNode.insertBefore this, this if isIE() paths.attr "d", (d) -> # Undocumented: https://github.com/tgdwyer/WebCola/issues/52 cola.vpsc.makeEdgeBetween \ d, d.source.innerBounds, d.target.innerBounds, # This value is related to but not equal to the # distance of arrow tip from object it points at: 5 lineData = [ {x: d.sourceIntersection.x, y: d.sourceIntersection.y}, {x: d.arrowStart.x, y: d.arrowStart.y} ] return lineFunction lineData lineFunction = d3.svg.line() .x((d) -> d.x) .y((d) -> d.y) .interpolate("linear") routeEdges = -> d3cola.prepareEdgeRouting EDGE_ROUTING_MARGIN paths.attr "d", (d) -> lineFunction d3cola.routeEdge(d) # show visibility graph # (g) -> # if d.source.id == 10 and d.target.id === 11 # g.E.forEach (e) => # vis.append("line").attr("x1", e.source.p.x).attr("y1", e.source.p.y) # .attr("x2", e.target.p.x).attr("y2", e.target.p.y) # .attr("stroke", "green") if isIE() paths.each (d) -> @parentNode.insertBefore this, this isIE = -> (navigator.appName is "Microsoft Internet Explorer") or ((navigator.appName is "Netscape") and ((new RegExp "Trident/.*rv:([0-9]{1,}[.0-9]{0,})") .exec(navigator.userAgent)?))