aboutsummaryrefslogtreecommitdiffstats
path: root/git_deps/html/js/git-deps-graph.coffee
diff options
context:
space:
mode:
Diffstat (limited to 'git_deps/html/js/git-deps-graph.coffee')
-rw-r--r--git_deps/html/js/git-deps-graph.coffee595
1 files changed, 595 insertions, 0 deletions
diff --git a/git_deps/html/js/git-deps-graph.coffee b/git_deps/html/js/git-deps-graph.coffee
new file mode 100644
index 0000000..7ad6827
--- /dev/null
+++ b/git_deps/html/js/git-deps-graph.coffee
@@ -0,0 +1,595 @@
+jQuery = require "jquery"
+$ = jQuery
+d3 = require "d3"
+d3tip = require "d3-tip"
+d3tip d3
+
+# Hacky workaround:
+# https://github.com/tgdwyer/WebCola/issues/145#issuecomment-271316856
+window.d3 = d3
+
+cola = require "webcola"
+
+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 <svg>, matching #svg-container border
+RECT_MARGIN = 14 # space in between <rects>
+PADDING = 5 # space in between <text> label and <rect> 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", 100)
+ .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
+ gdl.debug = options.debug
+
+ 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 = ->
+ 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()
+
+ gdl.build_constraints()
+ update_cola()
+
+ paths = fg.selectAll(".link")
+ .data(gdd.links, link_key)
+ paths.enter().append("svg:path")
+ .attr("class", "link")
+ .attr("stroke", (d) -> color(link_key(d)))
+ nodes = fg.selectAll(".node")
+ .data(gdd.nodes, (d) -> d.sha1)
+ global.nodes = nodes
+
+ g_enter = nodes.enter().append("g")
+ .attr("class", "node")
+ # Questionable attempt to use dagre layout as starting positions
+ # https://github.com/tgdwyer/WebCola/issues/63
+ nodes.each (d, i) ->
+ n = gdl.node d.sha1
+ d.x = n.x
+ d.y = n.y
+ nodes.attr "transform", (d) ->
+ translate d.x, d.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
+
+# A wrapper around tip.show is required to perform multiple visual
+# actions when the mouse hovers over a node; however even if the only
+# action required was to show the tool tip, the wrapper would still be
+# required in order 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
+# node_mouseover 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.
+node_mouseover = (d, i) ->
+ tip.show d, i, nodes[0][i]
+ highlight_nodes d3.select(nodes[0][i]), false
+ highlight_parents(d, i, true)
+ highlight_children(d, i, true)
+
+node_mouseout = (d, i) ->
+ tip.hide d, i, nodes[0][i]
+ highlight_nodes d3.select(nodes[0][i]), false
+ highlight_parents(d, i, false)
+ highlight_children(d, i, false)
+
+highlight_parents = (d, i, highlight) ->
+ sha1 = gdd.nodes[i].sha1
+ parents = nodes.filter (d, i) ->
+ d.sha1 of (gdd.rdeps[sha1] || {})
+ highlight_nodes parents, highlight, 'rgb(74, 200, 148)'
+
+highlight_children = (d, i, highlight) ->
+ sha1 = gdd.nodes[i].sha1
+ children = nodes.filter (d, i) ->
+ d.sha1 of (gdd.deps[sha1] || {})
+ highlight_nodes children, highlight, 'rgb(128, 197, 247)'
+
+highlight_nodes = (selection, highlight, colour='#c0c0c0') ->
+ selection.selectAll('rect')
+ .transition()
+ .ease('cubic-out')
+ .duration(200)
+ .style('stroke', if highlight then colour else '#e5e5e5')
+ .style('stroke-width', if highlight then '4px' else '2px')
+
+tip_dragend_handler = (d, i, elt) ->
+ focus_commitish_input()
+ node_mouseover 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]
+ query = new_data[2]
+ notification =
+ if query.revspec == query.tip_sha1
+ "Analysed dependencies of #{query.revspec}"
+ else if query.revisions.length == 1
+ "<span class=\"commit-ref\">#{query.revspec}</span>
+ resolved as #{query.tip_abbrev}"
+ else
+ "<span class=\"commit-ref\">#{query.revspec}</span>
+ expanded; tip is #{query.tip_abbrev}"
+ notification += "<p>#{new_nodes} new commit"
+ notification += "s" unless new_nodes == 1
+ notification += "; #{new_deps} new " +
+ (if new_deps == 1 then "dependency" else "dependencies")
+ notification += "</p>"
+
+ 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", 6)
+ .attr("markerHeight", 6)
+ .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", node_mouseover)
+ .on("mouseout", node_mouseout)
+
+ # Centre labels
+ labels
+ .attr("x", (d) -> d.rect_width / 2)
+ .attr("y", (d) -> d.rect_height / 2)
+ .on("mouseover", node_mouseover)
+ .on("mouseout", node_mouseout)
+
+ 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 " <span />"
+ 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("<br />Dependencies: " + sha1s.join(", "));
+ index = gdd.node_index[d.sha1]
+ debug = "<br />node index: " + index
+ dagre_node = gdl.graph.node(d.sha1)
+ debug += "<br />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
+ route = cola.makeEdgeBetween \
+ 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: route.sourceIntersection.x, y: route.sourceIntersection.y},
+ {x: route.arrowStart.x, y: route.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)?))