diff options
author | Adam Spiers <git@adamspiers.org> | 2016-06-11 22:20:04 +0100 |
---|---|---|
committer | Adam Spiers <git@adamspiers.org> | 2018-05-15 13:42:16 +0100 |
commit | 2c9d23b0291157eb1096384ff76e0122747b9bdf (patch) | |
tree | 524c7b479b65a478c998c28475d52e636b919200 /git_deps/html/js | |
parent | 9a741f07167dcb6cc81a8f87036d1ea75c4270d3 (diff) | |
download | git-deps-2c9d23b0291157eb1096384ff76e0122747b9bdf.tar.gz |
convert into a proper Python module
Sem-Ver: api-break
Diffstat (limited to 'git_deps/html/js')
-rw-r--r-- | git_deps/html/js/.gitignore | 1 | ||||
-rw-r--r-- | git_deps/html/js/fullscreen.js | 48 | ||||
-rw-r--r-- | git_deps/html/js/git-deps-data.coffee | 108 | ||||
-rw-r--r-- | git_deps/html/js/git-deps-graph.coffee | 595 | ||||
-rw-r--r-- | git_deps/html/js/git-deps-layout.coffee | 253 | ||||
-rw-r--r-- | git_deps/html/js/git-deps-noty.coffee | 32 |
6 files changed, 1037 insertions, 0 deletions
diff --git a/git_deps/html/js/.gitignore b/git_deps/html/js/.gitignore new file mode 100644 index 0000000..0e804e3 --- /dev/null +++ b/git_deps/html/js/.gitignore @@ -0,0 +1 @@ +bundle.js diff --git a/git_deps/html/js/fullscreen.js b/git_deps/html/js/fullscreen.js new file mode 100644 index 0000000..6d8f3d8 --- /dev/null +++ b/git_deps/html/js/fullscreen.js @@ -0,0 +1,48 @@ + +function endFullScreen(oncancel) { + if (!RunPrefixMethod(document, "FullScreen") && !RunPrefixMethod(document, "IsFullScreen")) { + oncancel(); + } +} +function fullScreen(e, oncancel) { + if (RunPrefixMethod(document, "FullScreen") || RunPrefixMethod(document, "IsFullScreen")) { + RunPrefixMethod(document, "CancelFullScreen"); + } + else { + RunPrefixMethod(e, "RequestFullScreen"); + e.setAttribute("width", screen.width); + e.setAttribute("height", screen.height); + } + if (arguments.length > 1) { + var f = function () { endFullScreen(oncancel); }; + document.addEventListener("fullscreenchange", f, false); + document.addEventListener("mozfullscreenchange", f, false); + document.addEventListener("webkitfullscreenchange", f, false); + } +} + +var pfx = ["webkit", "moz", "ms", "o", ""]; +function RunPrefixMethod(obj, method) { + + var p = 0, m, t; + while (p < pfx.length && !obj[m]) { + m = method; + if (pfx[p] == "") { + m = m.substr(0, 1).toLowerCase() + m.substr(1); + } + m = pfx[p] + m; + t = typeof obj[m]; + if (t != "undefined") { + pfx = [pfx[p]]; + return (t == "function" ? obj[m]() : obj[m]); + } + p++; + } +} + +function isFullScreen() { + var fullscreenEnabled = document.fullscreenEnabled || document.mozFullScreenEnabled || document.webkitFullscreenEnabled; + return fullscreenEnabled; +} + +module.exports = fullScreen; diff --git a/git_deps/html/js/git-deps-data.coffee b/git_deps/html/js/git-deps-data.coffee new file mode 100644 index 0000000..34715a3 --- /dev/null +++ b/git_deps/html/js/git-deps-data.coffee @@ -0,0 +1,108 @@ +# The list of nodes and links to feed into WebCola. +# These will be dynamically built as we retrieve them via XHR. +nodes = [] +links = [] + +# WebCola requires links to refer to nodes by index within the +# nodes array, so as nodes are dynamically added, we need to +# be able to retrieve their index efficiently in order to add +# links to/from them. This also allows us to avoid adding the +# same node twice. +node_index = {} + +# Track dependencies in a hash of hashes which maps parents to +# children to booleans. Constraints will be added to try to keep +# siblings at the same y position. For this we need to track +# siblings, which we do by mapping each parent to an array of its +# siblings in this hash. It also enables us to deduplicate links +# across multiple XHRs. +deps = {} + +# Track dependences in reverse in a hash of hashes which maps children +# to parents to booleans. This allows us to highlight parents when +# the mouse hovers over a child, and know when we can safely remove +# a commit due to its sole parent being deleted. +rdeps = {} + +# Returns 1 iff a node was added, otherwise 0. +add_node = (commit) -> + if commit.sha1 of node_index + n = node commit.sha1 + n.explored ||= commit.explored + return 0 + + nodes.push commit + node_index[commit.sha1] = nodes.length - 1 + return 1 + +# Returns 1 iff a dependency was added, otherwise 0. +add_dependency = (parent_sha1, child_sha1) -> + deps[parent_sha1] = {} unless parent_sha1 of deps + + # We've already got this link, presumably + # from a previous XHR. + return 0 if child_sha1 of deps[parent_sha1] + deps[parent_sha1][child_sha1] = true + add_link parent_sha1, child_sha1 + return 1 + +# Returns 1 iff a reverse dependency was added, otherwise 0. +add_rev_dependency = (child_sha1, parent_sha1) -> + rdeps[child_sha1] = {} unless child_sha1 of rdeps + + # We've already got this link, presumably + # from a previous XHR. + return 0 if parent_sha1 of rdeps[child_sha1] + rdeps[child_sha1][parent_sha1] = true + return 1 + +add_link = (parent_sha1, child_sha1) -> + pi = node_index[parent_sha1] + ci = node_index[child_sha1] + link = + source: pi + target: ci + value: 1 # no idea what WebCola needs this for + + links.push link + return + +# Returns true iff new data was added. +add_data = (data) -> + new_nodes = 0 + new_deps = 0 + for commit in data.commits + new_nodes += add_node(commit) + + for dep in data.dependencies + new_deps += add_dependency(dep.parent, dep.child) + add_rev_dependency(dep.child, dep.parent) + + if new_nodes > 0 or new_deps > 0 + return [ + new_nodes + new_deps + data.query + ] + + return false + +node = (sha1) -> + i = node_index[sha1] + unless i? + console.error "No index for SHA1 '#{sha1}'" + return null + return nodes[i] + +module.exports = + # Variables (N.B. if these variables are reinitialised at any + # point, the values here will become stale and require updating) + nodes: nodes + links: links + node_index: node_index + deps: deps + rdeps: rdeps + + # Functions + add: add_data + node: node 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)?)) diff --git a/git_deps/html/js/git-deps-layout.coffee b/git_deps/html/js/git-deps-layout.coffee new file mode 100644 index 0000000..8b8cd05 --- /dev/null +++ b/git_deps/html/js/git-deps-layout.coffee @@ -0,0 +1,253 @@ +DEBUG = false + +MIN_ROW_GAP = 60 +MIN_NODE_X_GAP = 100 # presumably includes the node width +MAX_NODE_X_GAP = 300 +MAX_NODE_Y_GAP = 80 + +dagre = require "dagre" + +gdd = require "./git-deps-data.coffee" + +# The list of constraints to feed into WebCola. +constraints = [] + +# Group nodes by row, as assigned by the y coordinates returned from +# dagre's layout(). This will map a y coordinate onto all nodes +# within that row. +row_groups = {} + +debug = (msg) -> + if exports.debug + console.log msg + +dagre_layout = -> + g = new dagre.graphlib.Graph() + exports.graph = g + + # Set an object for the graph label + g.setGraph {} + + # Default to assigning a new object as a label for each new edge. + g.setDefaultEdgeLabel -> {} + + for node in gdd.nodes + g.setNode node.sha1, + label: node.name + width: node.rect_width or 70 + height: node.rect_height or 30 + + for parent_sha1, children of gdd.deps + for child_sha1, bool of children + g.setEdge parent_sha1, child_sha1 + + dagre.layout g + return g + +dagre_row_groups = -> + g = dagre_layout() + row_groups = {} + exports.row_groups = row_groups + for sha1 in g.nodes() + x = g.node(sha1).x + y = g.node(sha1).y + row_groups[y] = [] unless y of row_groups + row_groups[y].push + sha1: sha1 + x: x + + for y, nodes of row_groups + nodes.sort (n) -> -n.x + + return row_groups + +build_constraints = -> + row_groups = dagre_row_groups() + debug "build_constraints" + for y, row_nodes of row_groups + debug y + debug row_nodes + + constraints.length = 0 # FIXME: only rebuild constraints which changed + + # We want alignment constraints between all nodes which dagre + # assigned the same y value. + #row_alignment_constraints(row_groups) + + # We need separation constraints ensuring that the left-to-right + # ordering within each row assigned by dagre is preserved. + for y, row_nodes of row_groups + # No point having an alignment group with only one node in. + continue if row_nodes.length <= 1 + + # Multiple constraints per row. + debug "ordering for row y=#{y}" + row_node_ordering_constraints(row_nodes) + debug_constraints() + + # We need separation constraints ensuring that the top-to-bottom + # ordering assigned by dagre is preserved. Since all nodes within + # a single row are already constrained to the same y coordinate + # from above, one would have hoped it would be enough to only have + # separation between a single node in adjacent rows: + # + # row_ordering_constraints(row_groups) + + # However, due to https://github.com/tgdwyer/WebCola/issues/61 + # there is more flexibility for y-coordinates within a row than we + # want, so instead we order rows using dependencies. + dependency_ordering_constraints() + +debug_constraints = (cs = constraints) -> + for c in cs + debug c + return + +row_alignment_constraints = (row_groups) -> + row_alignment_constraint(row_nodes) \ + for y, row_nodes of row_groups when row_nodes.length > 1 + +row_alignment_constraint = (row_nodes) -> + debug 'row_alignment_constraint' + # A standard alignment constraint (one per row) is too strict + # because it doesn't give cola enough "wiggle room": + # + # constraint = + # axis: "y" + # type: "alignment" + # offsets: [] + # + # for node in row_nodes + # constraint.offsets.push + # node: gdd.node_index[node.sha1], + # offset: 0 + # + # constraints.push constraint + # + # So instead we use vertical min/max separation constraints: + i = 0 + while i < row_nodes.length - 1 + left = row_nodes[i] + right = row_nodes[i+1] + mm = max_unordered_separation_constraints \ + 'y', MAX_NODE_Y_GAP, + gdd.node_index[left.sha1], + gdd.node_index[right.sha1] + exports.constraints = constraints = constraints.concat mm + i++ + debug_constraints() + return + +row_node_ordering_constraints = (row_nodes) -> + debug 'row_node_ordering_constraints' + i = 0 + while i < row_nodes.length - 1 + left = row_nodes[i] + right = row_nodes[i+1] + left_i = gdd.node_index[left.sha1] + right_i = gdd.node_index[right.sha1] + debug " #{left_i} < #{right_i} (#{left.x} < #{right.x})" + # mm = min_max_ordered_separation_constraints \ + # 'x', MIN_NODE_X_GAP, MAX_NODE_X_GAP, left_i, right_i + min = min_separation_constraint \ + 'x', MIN_NODE_X_GAP, left_i, right_i + exports.constraints = constraints = constraints.concat min + i++ + return + +row_ordering_constraints = (row_groups) -> + debug 'row_ordering_constraints' + row_y_coords = Object.keys(row_groups).sort() + + i = 0 + while i < row_y_coords.length - 1 + upper_y = row_y_coords[i] + lower_y = row_y_coords[i + 1] + upper_node = row_groups[upper_y][0] + lower_node = row_groups[lower_y][0] + constraints.push \ + min_separation_constraint \ + 'y', MIN_ROW_GAP, + gdd.node_index[upper_node.sha1], + gdd.node_index[lower_node.sha1] + + i++ + debug_constraints() + return + +dependency_ordering_constraints = () -> + debug 'dependency_ordering_constraints' + + for parent_sha1, children of gdd.deps + child_sha1s = Object.keys(children).sort (sha1) -> node(sha1).x + dependency_ordering_constraint(parent_sha1, child_sha1s[0]) + len = child_sha1s.length + if len > 1 + dependency_ordering_constraint(parent_sha1, child_sha1s[len-1]) + if len > 2 + middle = Math.floor(len / 2) + dependency_ordering_constraint(parent_sha1, child_sha1s[middle]) + + debug_constraints() + return + +dependency_ordering_constraint = (parent_sha1, child_sha1) -> + constraints.push \ + min_separation_constraint \ + 'y', MIN_ROW_GAP, + gdd.node_index[parent_sha1], + gdd.node_index[child_sha1] + +################################################################## +# helpers + +# Uses approach explained here: +# https://github.com/tgdwyer/WebCola/issues/62#issuecomment-69571870 +min_max_ordered_separation_constraints = (axis, min, max, left, right) -> + return [ + min_separation_constraint(axis, min, left, right), + max_separation_constraint(axis, max, left, right) + ] + +# https://github.com/tgdwyer/WebCola/issues/66 +max_unordered_separation_constraints = (axis, max, left, right) -> + return [ + max_separation_constraint(axis, max, left, right), + max_separation_constraint(axis, max, right, left) + ] + +min_separation_constraint = (axis, gap, left, right) -> + {} = + axis: axis + gap: gap + left: left + right: right + +# We use a negative gap and reverse the inequality, in order to +# achieve a maximum rather than minimum separation gap. However this +# does not prevent the nodes from overlapping or even swapping order. +# For that you also need a min_separation_constraint, but it's more +# convenient to use min_max_ordered_separation_constraints. See +# https://github.com/tgdwyer/WebCola/issues/62#issuecomment-69571870 +# for more details. +max_separation_constraint = (axis, gap, left, right) -> + {} = + axis: axis + gap: -gap + left: right + right: left + +node = (sha1) -> + exports.graph.node sha1 + +module.exports = exports = + # Variables have to be exported every time they're assigned, + # since assignment creates a new object and associated reference + + # Functions + build_constraints: build_constraints + debug_constraints: debug_constraints + node: node + + # Variables + debug: DEBUG diff --git a/git_deps/html/js/git-deps-noty.coffee b/git_deps/html/js/git-deps-noty.coffee new file mode 100644 index 0000000..cec2b08 --- /dev/null +++ b/git_deps/html/js/git-deps-noty.coffee @@ -0,0 +1,32 @@ +noty = require "noty" + +# Different noty types: +# alert, success, error, warning, information, confirmation +noty_error = (text) -> notyfication "error", text +noty_warn = (text) -> notyfication "warning", text +noty_success = (text) -> notyfication "success", text +noty_info = (text) -> notyfication "information", text +noty_debug = (text) -> notyfication "information", text + +# "notyfication" - haha, did you see what I did there? +notyfication = (type, text) -> + noty( + text: text + type: type + layout: "topRight" + theme: "relax" + maxVisible: 15 + timeout: 30000 # ms + animation: + open: "animated bounceInUp" # Animate.css class names + close: "animated bounceOutUp" # Animate.css class names + easing: "swing" # unavailable - no need + speed: 500 # unavailable - no need + ) + +module.exports = + error: noty_error + warn: noty_warn + success: noty_success + info: noty_info + debug: noty_debug |