7033f67074594c3d9cc3487f079456928fdf3260 angie Tue Apr 12 13:46:14 2016 -0700 Major change to hgGateway: the contents are replaced by a new page designed by a graphic artist. It has icons for selecting popular species, an autocomplete input for typing in species or common names, as well as a phylogenetic tree display that shows the relationships of the species that we host. It has a menu for selecting the assembly of the selected species' genome and the usual assembly description. refs #15277 diff --git src/hg/js/hgGateway.js src/hg/js/hgGateway.js index b7dd63b..76a6401 100644 --- src/hg/js/hgGateway.js +++ src/hg/js/hgGateway.js @@ -1,30 +1,1547 @@ -$(document).ready(function() { - suggestBox.init(document.orgForm.db.value, - $('#suggestTrack').length > 0, - function(item) { - $('#positionDisplay').text(item.id); - $('#position').val(item.id); +// hgGateway - autocomplete + graphical interface to select species, assembly & position. + +// Copyright (C) 2016 The Regents of the University of California + +// Several modules are defined in this file -- if some other code needs them someday, +// they can be moved out to lib files. + +// function svgCreateEl: convenience function for creating new SVG elements, used by +// speciesTree and rainbow modules. + +// speciesTree: module that exports draw() function for drawing a phylogenetic tree +// (and list of hubs above the tree, if any) in a pre-existing SVG element -- see +// hg/hgGateway/hgGateway.html. + +// rainbow: module that exports draw() function and colors. draw() adds stripes using +// a spectrum of colors that are associated to species groups. The hgGateway view code +// uses coordinates of stripes within the tree image to create a corresponding "rainbow" +// slider bar to the left of the phylogenetic tree container. + +// autocompleteCat: customized JQuery autocomplete plugin that includes watermark and +// can display results broken down by category (for example, genomes from various +// assembly hubs and native genomes). + +// hgGateway: module of mostly view/controller code (model state comes directly from server). + +// Globals: +/* globals calculateHgTracksWidth */ // pragma for jshint; function is defined in utils.js +var dbDbTree = dbDbTree || ['dbDbTree is missing!', []]; +var cart = cart || undefined; + +function svgCreateEl(type, config) { + // Helper function for creating a new SVG element and initializing its + // properties and attributes. Type is something like 'rect', 'text', 'g', etc; + // config is an object like { id: 'newThingie', x: 0, y: 10, title: 'blah blah' }. + var svgns = 'http://www.w3.org/2000/svg'; + var xlinkns = 'http://www.w3.org/1999/xlink'; + var el = document.createElementNS(svgns, type); + var title, titleEl; + if (el) { + _.forEach(config, function(value, setting) { + if (setting === 'textContent') { + // Text content (the text in a text element or title element) is a property: + el.textContent = value; + } else if (setting === 'href') { + // href comes from a different namespace so must use setAttributeNS: + el.setAttributeNS(xlinkns, 'href', value); + } else if (setting === 'title') { + title = value; + } else if (setting === 'className') { + el.setAttribute('class', value); + } else { + // Most of the time we're just setting an attribute: + el.setAttribute(setting, value); + } + }); + } + // Mouseover title actually requires creating a child element. + // Strangely, if I did this in the above loop, the child element was lost if + // props/attributes were set afterwards!! So save title for last. + if (title) { + titleEl = document.createElementNS(svgns, 'title'); + titleEl.textContent = title; + el.appendChild(titleEl); + } + return el; +} + +///////////////////////////// Module: speciesTree ///////////////////////////// + +var speciesTree = (function() { + // SVG phylogenetic tree of species in the browser (with connected assembly hubs above tree) + + // Layout parameters/configuration (object passed into draw can override these defaults): + var cfg = { labelRightX: 230, + labelStartY: 18, + speciesLineOffsetX: 8, + branchLength: 5, + halfTextHeight: 4, + hubLineOffset: 100, + labelLineHeight: 18, + paddingRight: 5, + paddingBottom: 5, + branchPadding: 2, + onClickSpeciesName: 'console.log', + onClickHubName: 'console.log', + trackHubsUrl: '', + containerWidth: 370 + }; + + function checkTree(node) { + // Return true if node and its descendants are of the form + // Array[ label:String, taxId:Number, sciName:String, Array[ [node]...] ] + // e.g. ['root', taxId0, sciName0, + // [ ['leaf1', taxId1, sciName1, []], + // ['leaf2', taxId2, sciName2, []] ] + // ] + if (! _.isString(node[0])) { + console.log('label is not a string', node); + return false; + } + if (! _.isNumber(node[1])) { + console.log('taxId is not a number', node); + return false; + } + if (! (node[2] === null || _.isString(node[2]))) { + console.log('sciName is not null or string', node); + return false; + } + return _.every(node[3], checkTree); + } + + function addDepth(node) { + // Each node is of the form [ label, taxId, sciName, node[] ] + // e.g. ['root', taxId0, sciName0, + // [ ['leaf1', taxId1, sciName1, []], + // ['leaf2', taxId2, sciName2, []] ] + // ] + // Add a fifth property to node: depth, i.e. the maximum number of + // branching nodes along any path from node to a leaf. + // Returns depth. + var kids = node[3]; + var depth, deepestKid; + if (!kids || kids.length === 0) { + // Leaf: depth is 0 + depth = 0; + } else if (kids.length === 1) { + // Node with one child: pass-through, depth is child's depth + depth = addDepth(kids[0]); + } else { + // Node with multiple children: depth is 1 + max child depth + deepestKid = _.max(kids, addDepth); + depth = 1 + deepestKid[4]; + } + node[4] = depth; + return depth; + } + + function addSpeciesLabel(svg, label, taxId, sciName, y) { + // Add a species label to svg at y offset + var onClickString = cfg.onClickSpeciesName + '(' + taxId + ')'; + var text = svgCreateEl('text', { x: cfg.labelRightX, y: y, + name: 'textEl_' + taxId, + title: sciName, + textContent: label, + onclick: onClickString }); + svg.appendChild(text); + } + + function addLine(svg, x1, y1, x2, y2, mouseover) { + // Add <line x1=x1, y1=y1, x2=x2, y2=y2 /> optionally with mouseover title + var config = { x1: x1, y1: y1, + x2: x2, y2: y2, + title: mouseover }; + var line; + if (x1 === x2) { + // vertical lines get special styling + config.className = 'vert'; + } + line = svgCreateEl('line', config); + svg.appendChild(line); + } + + function calcBranchX(kidRetList, depth, parentDepth, parentNodeDepth) { + // Return the x offset used for drawing lines from children and a vertical line + // connecting them. + // If the branch from parent is longer than the minimum length, we can scale myX + // to reflect how many nodes are skipped in branches to the right vs. to the left, + // ensuring the minimum branch length on either side. Without this adjustment, + // we sometimes get very long branches that skip only one node to the right of short + // branches that skip many nodes. + var myX; + var kidMaxX = _.max(kidRetList, 'x').x; + var branchSteps = parentDepth - depth; + var kidMinNodeDepth, ratioSkipped, stepsX; + if (branchSteps > 1) { + kidMinNodeDepth = _.min(kidRetList, 'nodeDepth').nodeDepth; + ratioSkipped = kidMinNodeDepth / (kidMinNodeDepth + parentNodeDepth); + stepsX = (branchSteps+1) * ratioSkipped; + // Make sure there is at least one step (min branch width) before and after + // stepsX. + if (stepsX < 1) { + stepsX = 1; + } else if (stepsX > branchSteps) { + stepsX = branchSteps; + } + myX = kidMaxX + cfg.branchLength * stepsX; + } else { + // Only one change in depth level, so short branch: + myX = kidMaxX + cfg.branchLength; + } + return myX; + } + + function drawNode(svg, node, leafY, leafTops, parentDepth, parentNodeDepth) { + // Each node is of the form [ label, taxId, sciName, node[], depth ] + // e.g. ['root', taxId0, sciName0, + // [ ['leaf1', taxId1, sciName1, [], 0], + // ['leaf2', taxId2, sciName2, [], 0] ], + // 1, + // ] + // depth is the max number of branching nodes under this node. + // Recursively draw the tree by generating new SVG elements and adding them to svg. + // Returns {x:, y:, leafY:} where x and y are the endpoint for the parent's branch + // to this node and leafY is the Y offset for the next leaf label. + // For leaf nodes, draw labels, store y in leafTops, and return label coordinates. + // Nodes with a single child don't draw anything, they simply return the + // child's info. + // Nodes with multiple children draw a horizontal line to each child and + // a vertical line connecting the horizontal lines. They return the {X,Y} of + // the midpoint of the vertical line. + // optional arg parentDepth is the depth of the parent node as defined above. + // optional arg parentNodeDepth is 1 plus the number of single-child nodes that were + // skipped on the way from the last branching ancestor. + // For internal use, the return object also includes nodeDepth: 1 + number of + // nodes skipped between this node and the next branching descendant or leaf + // and mouseover: horizontal line labels showing child label plus any skipped + // nodes' labels. + var label = node[0], taxId = node[1], sciName = node[2], kids = node[3], depth = node[4]; + var myX, myY; + var kidRet, kidRetList, kidMinY, kidMaxY, extraSpace; + parentDepth = parentDepth || 1; + parentNodeDepth = parentNodeDepth || 1; + if (!kids || kids.length === 0) { + // leaf node: draw species label, store myY in leafTops + addSpeciesLabel(svg, label, taxId, sciName, leafY); + myX = cfg.labelRightX + cfg.speciesLineOffsetX; + myY = leafY - cfg.halfTextHeight; + leafTops[label] = leafY - cfg.labelLineHeight; + return { x: myX, y: myY, leafY: leafY + cfg.labelLineHeight, nodeDepth: 1 }; + } else if (kids.length === 1) { + // Single child: don't draw anything (keep the rendering compact), + // but make a note that we skipped a node so we can use it for mouseover text + kidRet = drawNode(svg, kids[0], leafY, leafTops, parentDepth, parentNodeDepth + 1); + kidRet.nodeDepth++; + if (kidRet.mouseover) { + kidRet.mouseover += ' - ' + label; + } else { + kidRet.mouseover = label; + } + return kidRet; + } else { + // Multiple children. First pass to draw kids and gather their coords, + // second pass to draw lines connecting kids. + extraSpace = (depth - 1) * cfg.branchPadding; + kidRetList = _.map(kids, function(kid) { + var kidRet = drawNode(svg, kid, leafY, leafTops, depth, 1); + if (! kidRet.mouseover) { + // If nothing was skipped, use kid's sciName / label (same as kid's vert line) + var kidLabel = kid[0], kidSciName = kid[2]; + kidRet.mouseover = kidSciName ? kidSciName : kidLabel; + } + leafY = kidRet.leafY + extraSpace; + return kidRet; + }); + myX = calcBranchX(kidRetList, depth, parentDepth, parentNodeDepth); + // Draw horizontal lines from kids + _.forEach(kidRetList, function(kidRet) { + addLine(svg, kidRet.x, kidRet.y, myX, kidRet.y, kidRet.mouseover); + }); + // Draw vertical line connecting kids + kidMinY = kidRetList[0].y; + kidMaxY = kidRetList[kids.length-1].y; + addLine(svg, myX, kidMinY, myX, kidMaxY, label); + myY = (kidMinY + kidMaxY) / 2; + return { x: myX, y: myY, leafY: leafY, nodeDepth: 1, mouseover: label }; + } + } + + function addTrackHubsLink(svg, x, y) { + // Add a label with link to hgHubConnect at the given position. + var a = svgCreateEl('a', { 'href': cfg.hgHubConnectUrl }); + var text = svgCreateEl('text', { x: x, y: y, + textContent: 'Hub Genomes', + className: 'trackHubsLink' }); + a.appendChild(text); + svg.appendChild(a); + } + + function doubleQuote(string) { + return '"' + string + '"'; + } + + function addHubLabel(svg, hub, y) { + // Add a track hub label to svg at y offset + var label = hub.shortLabel + ' (' + hub.assemblyCount + ')'; + // There are a bunch of hub properties to pass to the onClick handler; + // too bad we can't pass a bound function but instead must build a string: + var onClickString = cfg.onClickHubName + '(' + + doubleQuote(hub.hubUrl) + ', ' + + hub.taxId + ', ' + + doubleQuote(hub.defaultDb) + ', ' + + doubleQuote(hub.name) + + ')'; + var text = svgCreateEl('text', { x: cfg.labelRightX, y: y, + textContent: label, + name: 'textEl_' + hub.name, + onclick: onClickString, + title: hub.longLabel }); + svg.appendChild(text); + } + + function drawHubs(svg, hubList, yIn) { + // Add a label for each hub in hubList, with a separator line below and + // "Hub Genomes" link instead of a tree. + var y = yIn; + var hub, i, textX, textY, lineX1, lineY, lineX2; + if (hubList && hubList.length) { + for (i = 0; i < hubList.length; i++) { + hub = hubList[i]; + addHubLabel(svg, hub, y); + y += cfg.labelLineHeight; + } + textX = cfg.labelRightX + cfg.speciesLineOffsetX; + textY = (yIn + y - cfg.labelLineHeight) / 2; + addTrackHubsLink(svg, textX, textY); + lineX1 = cfg.hubLineOffset; + lineY = y - cfg.halfTextHeight; + lineX2 = cfg.containerWidth - cfg.speciesLineOffsetX; + addLine(svg, lineX1, lineY, lineX2, lineY); + y += cfg.labelLineHeight; + } + return y; + } + + function draw(svg, dbDbTree, hubList, cfgOverrides) { + // dbDbTree is the root node of a phylogenetic tree that we render in svg. + // cfgOverrides should be used to provide (names of) meaningful onclick functions + // and track hub URL. + // Return the width and height of the tree, the top offset of the tree (below hubs + // if any), and an object with the top coords of each label/leaf. + // Instead of tacking a bunch of children directly onto svg, make a <g> (group) + // and append that to svg when done. + // First see if there's already something there that we will replace: + var oldG = svg.getElementById('hubsAndTree'); + var newG = svgCreateEl('g', { id: 'hubsAndTree' }); + // y offsets of tops of species labels (leaves of dbDbTree), filled in by drawNode. + var leafTops = {}; + var hubBottomY, treeInfo, width, height; + if (! checkTree(dbDbTree)) { + console.error('dbDbTree in wrong format', dbDbTree); + return; + } + _.assign(cfg, cfgOverrides); + hubBottomY = drawHubs(newG, hubList, cfg.labelStartY); + addDepth(dbDbTree); + treeInfo = drawNode(newG, dbDbTree, hubBottomY, leafTops); + width = treeInfo.x + cfg.paddingRight; + height = treeInfo.leafY - cfg.labelLineHeight + cfg.paddingBottom; + if (oldG) { + svg.removeChild(oldG); + } + svg.appendChild(newG); + return { width: width, height: height, + yTree: hubBottomY - cfg.labelLineHeight, + leafTops: leafTops }; + } + + return { draw: draw }; + +}()); // speciesTree + + +///////////////////////////// Module: rainbow ///////////////////////////// + +var rainbow = (function() { + // Add rainbow stripes with cute species icons on the left of an SVG element that + // already has a phylogenetic tree drawn by speciesTree. + + // Layout parameters/configuration (object passed into draw can override these defaults): + var cfg = { stripeWidth: 69, + iconX: 2, + iconYOffset: -14, + iconWidth: 65, + iconHeight: 65, + iconSpriteUrl: '../images/jWestIconsAlpha65px.png', + iconSpriteWidth: 325, + iconSpriteHeight: 325 + }; + + // Color spectrum for slider and tree display: + var stripeColors = [ '#7E1F16', + '#A12321', + '#C15026', + '#DF6933', + '#EB8734', + '#B57E2A', + '#CD9C2A', + '#CFB32B', + '#959E38', + '#3A8349', + '#216D6D', + '#4C749B', + '#31469A', + '#7E4475', + '#231F1F' ]; + + // Hubs go above the species rainbow -- give them their own color of stripe: + var hubColor = '#60180C'; + + // Taxonomy IDs for assigning rainbow stripes to species groups: + var stripeTaxIds = [ 9443, // Primates + 314146, // Euarchontoglires + 9362, // Insectivora + 91561, // Cetartiodactyla + 314145, // Laurasiatheria + 9397, // Chiroptera + 9347, // Eutheria + 40674, // Mammalia + 32523, // Tetrapoda + 7742, // Vertebrata + 33511, // Deuterostomia + 33392, // Endopterygota + 6072, // Eumetazoa + 2759, // Eukaryota + 1 ]; // root + + // Cute species icons placed along the stripe also help to orient. + // This maps leaf labels to icon names: + var iconSpeciesToName = { Human: 'Human', + Mouse: 'Mouse', + 'D. melanogaster': 'Fly', + 'C. elegans': 'Worm', + 'S. cerevisiae': 'Yeast', + 'Rhesus': 'Monkey', + Hedgehog: 'Hedgehog', + // Pig: 'Pig', + Cow: 'Cow', + 'Killer whale': 'Orca', + Horse: 'Horse', + Dog: 'Dog', + 'Pacific walrus': 'Walrus', + 'Megabat': 'Bat', + Elephant: 'Elephant', + Manatee: 'Manatee', + Armadillo: 'Armadillo', + 'Wallaby': 'Kangaroo', + 'Zebra finch': 'Bird', + Lizard: 'Lizard', + 'X. tropicalis': 'Frog', + 'Fugu': 'Fish', + 'Ebola virus': 'Ebola' + }; + + // The icon sprite image has 5 rows and 5 columns: + var spriteRowCol = { Human: [0,0], + Mouse: [0,1], + Fly: [0,3], + Worm: [0,4], + Yeast: [1,0], + Monkey: [1,1], + Hedgehog: [1,2], + Pig: [1,3], + Cow: [1,4], + Orca: [2,0], + Horse: [2,1], + Dog: [2,2], + Walrus: [2,3], + Bat: [2,4], + Elephant: [3,0], + Manatee: [3,1], + Armadillo: [3,2], + Kangaroo: [3,3], + Bird: [3,4], + Lizard: [4,0], + Frog: [4,1], + Fish: [4,2], + Ebola: [4,3] + }; + + // Some icon drawings are shorter than others, and some need to be moved up to make space + // for close neighbors. + var iconFudgeY = { Human: 20, + Mouse: 0, + Fly: 0, + Worm: 0, + Monkey: 0, + Hedgehog: -18, + Pig: -5, + Cow: 0, + Orca: 0, + Horse: -8, + Dog: 15, + Walrus: 0, + Bat: 0, + Elephant: -20, + Manatee: 20, + Armadillo: 0, + Kangaroo: 0, + Bird: 0, + Lizard: -20, + Frog: -10, + Fish: 0, + Yeast: -15, + Ebola: 0 + }; + + function findStripeTops(node, parentStripeIx, leafTops, stripeTops, yPrev) { + // Each node is of the form [ label, taxId, sciName, node[], ... ] + // Recursively find the top coordinate of each stripe in stripeTaxIds, + // using node taxId. Modifies stripeTops. Returns the y of the top of the + // last leaf visited. + var label = node[0], taxId = node[1], kids = node[3]; + var stripeIx = stripeTaxIds.indexOf(taxId); + var i; + // Inherit parent stripe unless this node is found in stripeTaxIds: + if (stripeIx < 0) { + stripeIx = parentStripeIx; + } + if (!kids || kids.length === 0) { + // leaf node: if this stripe's top coord has not yet been assigned, + // assign it. + if (stripeTops[stripeIx] === undefined) { + stripeTops[stripeIx] = (leafTops[label] + yPrev) / 2; + } + yPrev = leafTops[label]; + } else { + // descend to children + for (i = 0; i < kids.length; i++) { + yPrev = findStripeTops(kids[i], stripeIx, leafTops, stripeTops, yPrev); + } + } + return yPrev; + } + + function addRectFill(svg, x, y, width, height, color) { + // Add filled rectangle to svg + var rect = svgCreateEl('rect', { x: x, y: y, + width: width, height: height, + style: 'fill:' + color + '; stroke:' + color }); + svg.appendChild(rect); + } + + function drawStripes(svg, dbDbTree, yTop, height, leafTops) { + var stripeCount = stripeColors.length; + var lastStripeIx = stripeCount - 1; + var stripeTops = []; + var i, stripeHeight; + findStripeTops(dbDbTree, lastStripeIx, leafTops, stripeTops, yTop); + // Add an extra "stripe" coord so we have the coord for the bottom of the last stripe: + stripeTops[stripeCount] = height; + // Initialize missing stripes to 0-height (top = next stripe's top), if any: + for (i = stripeCount - 1; i >= 0; i--) { + if (stripeTops[i] === undefined) { + console.warn("No species found for stripe " + i + ", taxId " + stripeTaxIds[i]); + stripeTops[i] = stripeTops[i+1]; + } + } + for (i = 0; i < stripeCount; i++) { + stripeHeight = stripeTops[i+1] - stripeTops[i]; + addRectFill(svg, 0, stripeTops[i], cfg.stripeWidth, stripeHeight, stripeColors[i]); + } + // Add stripe for hubs, if any: + if (yTop > 0) { + addRectFill(svg, 0, 0, cfg.stripeWidth, yTop, hubColor); + } + return stripeTops; + } + + function drawOneIcon(svg, name, y) { + // Create an image, offset so that the icon is positioned where we need it, + // and use a clip-path to limit display to just that icon, not the whole sprite image. + var iconY = y + cfg.iconYOffset + iconFudgeY[name]; + var rowCol = spriteRowCol[name]; + var row = rowCol[0], column = rowCol[1]; + var clipPathId = 'clip' + name; + var img = svgCreateEl('image', { x: cfg.iconX - (column * cfg.iconWidth), + y: iconY - (row * cfg.iconHeight), + width: cfg.iconSpriteWidth, + height: cfg.iconSpriteHeight, + style: 'clip-path: url(#' + clipPathId + ')', + href: cfg.iconSpriteUrl }); + // Set the y of the pre-existing clip path: + var rect = $('#' + clipPathId + ' rect')[0]; + rect.setAttribute('y', iconY); + svg.appendChild(img); + } + + function drawIcons(svg, leafTops) { + // For each icon listed in iconSpeciesToName, look up the species' y offset in + // the tree and add the icon to svg. + _.forEach(iconSpeciesToName, function (name, species) { + var y = leafTops[species]; + if (y >= 0) { + drawOneIcon(svg, name, y); + } + }); + } + + function draw(svg, dbDbTree, yTree, height, leafTops) { + // Draw stripes with colors corresponding to species groups and cute-species icons. + // Return y offsets of stripes so that a slider widget can be drawn accordingly. + // Instead of tacking a bunch of children directly onto svg, make a <g> (group) + // and append that to svg when done. + // First see if there's already something there that we will replace: + var oldG = svg.getElementById('stripesAndIcons'); + var newG = svgCreateEl('g', { id: 'stripesAndIcons' }); + var stripeTops = drawStripes(newG, dbDbTree, yTree, height, leafTops); + drawIcons(newG, leafTops); + if (oldG) { + svg.removeChild(oldG); + } + svg.appendChild(newG); + return stripeTops; + } + + return { draw: draw, + colors: stripeColors, + hubColor: hubColor + }; +}()); // rainbow + + +///////////////////////////// Module: autocompleteCat ///////////////////////////// + +var autocompleteCat = (function() { + // Customize jQuery UI autocomplete to show item categories and support html markup in labels. + // Adapted from https://jqueryui.com/autocomplete/#categories and + // http://forum.jquery.com/topic/using-html-in-autocomplete + // Also adds watermarm to input. + $.widget("custom.autocompleteCat", + $.ui.autocomplete, + { + _renderMenu: function(ul, items) { + var that = this; + var currentCategory = ""; + // There's no this._super as shown in the doc, so I can't override + // _create as shown in the doc -- just do this every time we render... + this.widget().menu("option", "items", "> :not(.ui-autocomplete-category)"); + $.each(items, + function(index, item) { + // Add a heading each time we see a new category: + if (item.category && item.category !== currentCategory) { + ul.append("<li class='ui-autocomplete-category'>" + + item.category + "</li>" ); + currentCategory = item.category; + } + that._renderItem( ul, item ); + }); }, - function(position) { - $('#positionDisplay').text(position); - $('#position').val(position); + _renderItem: function(ul, item) { + // In order to use HTML markup in the autocomplete, one has to overwrite + // autocomplete's _renderItem method using .html instead of .text. + // http://forum.jquery.com/topic/using-html-in-autocomplete + return $("<li></li>") + .data("item.autocomplete", item) + .append($("<a></a>").html(item.label)) + .appendTo(ul); + } }); - // Default the image width to current browser window width (#2633). - var ele = $('input[name=pix]'); - if (ele.length && (!ele.val() || ele.val().length === 0)) { - ele.val(calculateHgTracksWidth()); + function init($input, options) { + // Set up an autocomplete and watermark for $input, with a callback options.onSelect + // for when the user chooses a result. + // If options.baseUrl is null, the autocomplete will not do anything, but we (re)initialize + // it anyway in case the same input had a previous db's autocomplete in effect. + // If options.searchObj is provided, it is used in addition to baseUrl; first the term is + // looked up in searchObj and then also queried using baseUrl. Values in searchObj + // should have the same structure as the value returned by a baseUrl query. + // The function closure allows us to keep a private cache of past searches. + var cache = {}; + + var doSearch = function(term, acCallback) { + // Look up term in searchObj and by sending an ajax request + var timestamp = new Date().getTime(); + var url = options.baseUrl + encodeURIComponent(term) + '&_=' + timestamp; + var searchObjResults = []; + _.forEach(options.searchObj, function(results, key) { + if (_.startsWith(key.toUpperCase(), term.toUpperCase())) { + searchObjResults = searchObjResults.concat(results); } + }); + $.getJSON(url) + .done(function(results) { + var combinedResults = results.concat(searchObjResults); + cache[term] = combinedResults; + acCallback(combinedResults); + }); + // ignore errors to avoid spamming people on flaky network connections + // with tons of error messages (#8816). + }; - if ($("#suggestTrack").length) { - // Make sure suggestTrack is visible when user chooses something via gene select (#3484). - $(document.mainForm).submit(function(event) { - if ($('#hgFindMatches').length) { - var track = $("#suggestTrack").val(); - if (track) { - $("<input type='hidden' name='" + track + "'value='pack'>").appendTo($(event.currentTarget)); + var autoCompleteSource = function(request, acCallback) { + // This is a callback for jqueryui.autocomplete: when the user types + // a character, this is called with the input value as request.term and an acCallback + // for this to return the result to autocomplete. + // See http://api.jqueryui.com/autocomplete/#option-source + var results = cache[request.term]; + if (results) { + acCallback(results); + } else if (options.baseUrl) { + doSearch(request.term, acCallback); } + }; + + var autoCompleteSelect = function(event, ui) { + // This is a callback for autocomplete to let us know that the user selected + // a term from the list. See http://api.jqueryui.com/autocomplete/#event-select + options.onSelect(ui.item); + $input.blur(); + }; + + // Provide default values where necessary: + options.onSelect = options.onSelect || console.log; + options.searchObj = options.searchObj || {}; + options.enterSelectsIdentical = options.enterSelectsIdentical || false; + + $input.autocompleteCat({ + delay: 500, + minLength: 2, + source: autoCompleteSource, + select: autoCompleteSelect, + enterSelectsIdentical: options.enterSelectsIdentical, + enterTerm: options.onEnterTerm + }); + + if (options.watermark) { + $input.Watermark(options.watermark, '#686868'); + } + } + + return { init: init }; +}()); // autocompleteCat + + +///////////////////////////// Module: hgGateway ///////////////////////////// + +var hgGateway = (function() { + // Interactive parts of the new gateway page: species autocomplete, + // graphical species-picker, db select, and position autocomplete. + + // Constants + var speciesWatermark = 'Enter species or common name'; + var positionWatermark = 'Enter position, gene symbol or search terms'; + // Shortcuts to popular species: + var favIconTaxId = [ ['Human', 9606], + ['Mouse', 10090], + ['Rat', 10116], + ['Fly', 7227], + ['Worm', 6239], + ['Yeast', 559292] ]; + // Aliases for species autocomplete: + var commonToSciNames = { bats: 'Chiroptera', + bees: 'Apoidea', + birds: 'Aves', + fish: 'Actinopterygii', + fly: 'Diptera', + flies: 'Diptera', + frogs: 'Anura', + fruitfly: 'Drosophila', + 'fruit fly': 'Drosophila', + honeybees: 'Apinae', + 'honey bees': 'Apinae', + monkeys: 'Simiiformes', + mosquitos: 'Culicidae', + worms: 'Nematoda', + yeast: 'Ascomycota' }; + + var getBetterBrowserMessage = '<P style="padding-left: 10px;">' + + 'Our website has detected that you are using ' + + 'an outdated browser that will prevent you from ' + + 'accessing certain features. An update is not ' + + 'required, but it is strongly recommended to ' + + 'improve your browsing experience. ' + + 'Please use the following links to upgrade your ' + + 'existing browser to ' + + '<A HREF="https://www.mozilla.org/en-US/firefox/new/">' + + ' FireFox</A> or ' + + '<A HREF="https://www.google.com/chrome/browser/">' + + 'Chrome</A>.' + + '</P>'; + + // Globals + // Set this to true to see server requests and responses in the console. + var debugCartJson = false; + // This is a global (within wrapper function scope) so event handlers can use it + // without needing to bind functions. + var scrollbarWidth = 0; + // This holds everything we need to know to draw the page: taxId, db, hubs, description etc. + var uiState = {}; + + function setupFavIcons() { + // Set up onclick handlers for shortcut buttons and labels + var i, name, taxId, onClick; + for (i = 0; i < favIconTaxId.length; i++) { + name = favIconTaxId[i][0]; + taxId = favIconTaxId[i][1]; + // When user clicks on icon, set the taxId (default database); + // scroll the image to that species and clear the species autocomplete input. + onClick = setTaxId.bind(null, taxId, null, true, true); + // Onclick for both the icon and its sibling label: + $('.jwIconSprite' + name).parent().children().click(onClick); + } + } + + function addCategory(cat, item) { + // Clone item, add category: cat to it and return it (helper function, see below). + var clone = {}; + _.assign(clone, item, { category: cat }); + return clone; + } + + function autocompleteFromTree(node) { + // Traverse dbDbTree to make autocomplete result lists for all non-leaf node labels. + // Returns an object mapping each label of node and descendants to a list of + // result objects with the same structure that we'd get from a server request. + var searchObj = {}; + var myResults = []; + var label = node[0], taxId = node[1], kids = node[3]; + var addMyLabel; + if (!kids || kids.length === 0) { + // leaf node: return autocomplete result for species + myResults = [ { genome: label, + label: label, + taxId: taxId } ]; + } else { + // Accumulate the list of all children's result lists; + // keep each's child searchObj mappings unless the child is a leaf + // (which would be redundant with server autocomplete results). + addMyLabel = addCategory.bind(null, label); + myResults = _.flatten( + _.map(kids, function(kid) { + var kidLabel = kid[0], kidKids = kid[3]; + var kidObj = autocompleteFromTree(kid); + // Clone kid's result list and add own label as category: + var kidResults = _.map(kidObj[kidLabel], addMyLabel); + // Add kid's mappings to searchObj only if kid is not a leaf. + if (kidKids && kidKids.length > 0) { + _.assign(searchObj, kidObj); + } + return kidResults; + }) + ); + } + // Exclude some overly broad categories: + if (label !== 'root' && label !== 'cellular organisms') { + searchObj[label] = myResults; + } + return searchObj; + } + + function addAutocompleteCommonNames(searchObj) { + // After searchObj is constructed by autocompleteFromTree, add aliases for + // some common names that map to scientific names in the tree. + _.forEach(commonToSciNames, function(sciName, commonName) { + var label, addMyLabel; + if (searchObj[sciName]) { + label = sciName + ' (' + commonName + ')'; + addMyLabel = addCategory.bind(null, label); + searchObj[commonName] = _.map(searchObj[sciName], addMyLabel); } }); } + + function makeStripe(id, color, stripeHeight, scrollTop, onClickStripe) { + // Return an empty div with specified background color and height + var $stripe = $('<div class="jwRainbowStripe">'); + $stripe.attr('id', 'rainbowStripe' + id); + $stripe.attr('title', 'Click to scroll the tree display'); + $stripe.css('background-color', color); + $stripe.height(stripeHeight); + $stripe.click(onClickStripe.bind(null, scrollTop)); + return $stripe; + } + + function makeRainbowSliderStripes($slider, onClickStripe, svgHeight, stripeColors, stripeTops) { + // Set up the rainbow slider bar for the speciesPicker. + // The stripeColors array determines the number of stripes and their colors. + // stripeTops contains the pixel y coordinates of the top of each stripe; + // we convert these to pixel heights of HTML div stripes. + // onClickStripe is bound to the normalized top coord of each stripe + // (the ratio of stripe's top coord to slider bar height, in the range + // 0.0 to (1.0 - 1/stripeColors.length)). + var i, $stripe, scrollTop, stripeHeight, svgStripeHeight; + var sliderHeight = $('#speciesPicker').outerHeight(); + $slider.empty(); + if (stripeTops[0] > 0) { + // Add a placeholder stripe for hubs at the top + stripeHeight = sliderHeight * stripeTops[0] / svgHeight; + $stripe = makeStripe('Hub', rainbow.hubColor, stripeHeight, 0, onClickStripe); + $slider.append($stripe); + } + for (i = 0; i < stripeColors.length; i++) { + svgStripeHeight = stripeTops[i+1] - stripeTops[i]; + stripeHeight = sliderHeight * svgStripeHeight / svgHeight; + scrollTop = stripeTops[i] / svgHeight; + $stripe = makeStripe(i, stripeColors[i], stripeHeight, scrollTop, onClickStripe); + $slider.append($stripe); + } + } + + function resizeSliderIcon($sliderIcon, svgHeight, sliderBarHeight) { + // Make the slider icon's height cover the same portion of the slider bar + // as the visible part of the tree is compared to the entire tree image height. + var visiblePortion = _.min([1, sliderBarHeight / svgHeight]); + var iconHeight = visiblePortion * sliderBarHeight; + // Set the icon rectangle's height and triangle vertical offset. + var svg = document.getElementById('sliderSvg'); + var rect = document.getElementById('sliderRectangle'); + var tri = document.getElementById('sliderTriangle'); + var strokeWidth = 3, triangleHeight = 6; + svg.setAttribute('height', iconHeight); + rect.setAttribute('height', iconHeight - strokeWidth); + tri.setAttribute('d', 'm 2.5,' + ((iconHeight - triangleHeight) / 2) + ' 0,6 4,-3 z'); + $sliderIcon.height(iconHeight); + } + + function initRainbowSlider(svgHeight, stripeColors, stripeTops) { + // Once we know the height of the hubs & tree image, initialize the rainbow slider + // widget for coordinated scrolling. Dragging the slider causes the image to scroll. + // Scrolling the image causes the slider to move. Clicking on a stripe causes the + // image to scroll and the slider to move. + var $speciesPicker = $('#speciesPicker'); + var $sliderBar = $('#rainbowSlider'); + var sliderBarTop = $sliderBar.offset().top; + var sliderBarHeight = $speciesPicker.outerHeight(); + var $sliderIcon = $('#sliderSvgContainer'); + var sliderIconLeft = $sliderIcon.offset().left; + var $speciesTree = $('#speciesTree'); + // When the user moves the slider, causing the image to scroll, don't do the + // image onscroll action (do that only when the user scrolls the image). + var inhibitImageOnScroll = false; + // Don't let the slider hang off the bottom when the user clicks the bottom stripe: + var maxNormalizedTop = 1 - (sliderBarHeight / svgHeight); + + // Define several helper functions within this function scope so they can use + // the variables defined above. + var scrollImage = function(normalizedTop) { + // Scroll the hubs+tree image to a normalized top coord scaled by svgHeight. + $speciesPicker.scrollTop(svgHeight * normalizedTop); + }; + + var moveSlider = function(normalizedTop) { + // Move the slider icon to a normalized top coord scaled by sliderBarHeight. + $sliderIcon.offset({ top: sliderBarTop + (normalizedTop * sliderBarHeight), + left: sliderIconLeft.left }); + }; + + var onClickStripe = function(normalizedTop) { + // The user clicked a stripe; move the slider to the top of that stripe and + // scroll the tree image to the top of the corresponding stripe in the image. + inhibitImageOnScroll = true; + if (normalizedTop > maxNormalizedTop) { + normalizedTop = maxNormalizedTop; + } + scrollImage(normalizedTop); + moveSlider(normalizedTop); + }; + + var onDragSlider = function(event, ui) { + // The user dragged the slider; scroll the tree image to the corresponding + // position. + var sliderTop = ui.offset.top - sliderBarTop; + var normalizedTop = sliderTop / sliderBarHeight; + inhibitImageOnScroll = true; + scrollImage(normalizedTop); + }; + + var onScrollImage = function() { + // The user scrolled the image -- or the user did something else which caused + // the image to scroll, in which case we don't need to do anything more. + var imageTop, normalizedTop; + if (inhibitImageOnScroll) { + inhibitImageOnScroll = false; + return; + } + imageTop = -$speciesTree.offset().top + sliderBarTop + 1; + normalizedTop = imageTop / svgHeight; + moveSlider(normalizedTop); + }; + + // This might be called before the species image has been created; if so, do nothing. + if (! $speciesTree || speciesTree.length === 0) { + return; + } + + makeRainbowSliderStripes($sliderBar, onClickStripe, svgHeight, stripeColors, stripeTops); + resizeSliderIcon($sliderIcon, svgHeight, sliderBarHeight); + $sliderIcon.draggable({ axis: 'y', + containment: '#speciesGraphic', + drag: onDragSlider + }); + $sliderIcon.show(); + $speciesPicker.scroll(onScrollImage); + } + + function findScrollbarWidth() { + var widthPlain = $("#sbTestContainerDPlain").width(); + var widthInsideScroll = $("#sbTestContainerDInsideScroll").width(); + $('#sbTestContainer').hide(); + return widthPlain - widthInsideScroll; + } + + function setRightColumnWidth() { + // Adjust the width of the "Find Position" section so it fits to the right of the + // "Browse/Select Species" section. + var ieFudge = scrollbarWidth ? scrollbarWidth + 4 : 0; + var extraFudge = 4; + var rightColumnWidth = ($('#pageContent').width() - + $('#selectSpeciesSection').width() - + ieFudge - extraFudge); + if (rightColumnWidth >= 400) { + $('#findPositionSection').width(rightColumnWidth); + } + } + + function setSpeciesPickerSizes(svgWidth, svgHeight) { + // Adjust widths and heights of elements in #speciesPicker according to svg size. + $('#speciesTree').width(svgWidth); + $('#speciesTree').height(svgHeight); + $('#speciesTreeContainer').height(svgHeight); + // Make #speciesTreeContainer skinnier if a scrollbar is taking up space + // in #speciesPicker. + var leftover = ($("#speciesPicker").width() - scrollbarWidth); + $("#speciesTreeContainer").width(leftover); + } + + function highlightLabel(selectedName, scrollToItem) { + // Highlight the selected species. + // jQuery (at least our old version of it) can find the SVG text elements but can't + // directly manipulate their class, so do it manually. + var $sp = $('#speciesPicker'); + var y = $sp.scrollTop(); + $('svg text').each(function(ix, el) { + var elName = el.getAttribute('name'); + var elClass = el.getAttribute('class'); + if (!elClass || elClass.indexOf('trackHubsLabel') < 0) { + if (elName === selectedName) { + el.setAttribute('class', 'selected'); + y = el.getAttribute('y'); + } else if (elClass === 'selected') { + el.setAttribute('class', ''); + } + } }); + if (scrollToItem) { + $sp.scrollTop(y - 100); + } + } + + function hubNameFromDb(db) { + var matches = db ? db.match(/^(hub_[0-9]+)_/) : null; + if (matches) { + return matches[1]; + } else { + return null; + } + } + + function highlightLabelForDb(db, taxId) { + var hubName = hubNameFromDb(db); + if (hubName) { + highlightLabel('textEl_' + hubName, true); + } else { + highlightLabel('textEl_' + taxId, true); + } + } + + function drawSpeciesPicker() { + var svg, spTree, stripeTops; + if (document.createElementNS) { + // Draw the phylogenetic tree and do layout adjustments + svg = document.getElementById('speciesTree'); + spTree = speciesTree.draw(svg, dbDbTree, uiState.hubs, + { onClickSpeciesName: 'hgGateway.onClickSpeciesLabel', + onClickHubName: 'hgGateway.onClickHubName', + hgHubConnectUrl: 'hgHubConnect?hgsid=' + window.hgsid, + containerWidth: $('#speciesPicker').width() + }); + setSpeciesPickerSizes(spTree.width, spTree.height); + highlightLabelForDb(uiState.db, uiState.taxId); + stripeTops = rainbow.draw(svg, dbDbTree, spTree.yTree, spTree.height, spTree.leafTops); + initRainbowSlider(spTree.height, rainbow.colors, stripeTops); + } else { + $('#speciesTreeContainer').html(getBetterBrowserMessage); + } + } + + function onSelectGene(item) { + // Set the position from an autocomplete result; + // set hgFindMatches and make sure suggestTrack is in pack mode for highlighting the match. + var newPos = item.id; + var settings; + $('#positionDisplay').text(newPos); + if (uiState.suggestTrack) { + settings = { 'hgFind.matches': item.internalId }; + settings[uiState.suggestTrack] = 'pack'; + cart.send({ cgiVar: settings }); + cart.flush(); + } + // Overwrite the selected item w/actual position after the autocomplete plugin is done: + function overwriteWithPos() { + $('#positionInput').val(newPos); + } + window.setTimeout(overwriteWithPos, 0); + } + + function updateGoButtonPosition() { + // If there's room for the go button to appear to the right of #selectAssembly and + // #positionInput, align the top of the go button with the bottom of #selectAssembly. + // Otherwise let it hang out below. + var $fpic = $('#findPosInputContainer'); + var fpicOffset = $fpic.offset(); + var fpicRight = fpicOffset.left + $fpic.width(); + var $button = $('.jwGoButtonContainer'); + var buttonOffset = $button.offset(); + var $select; + if (buttonOffset.left > fpicRight) { + // Align button top with select bottom. + $select = $('#selectAssembly'); + buttonOffset.top = $select.offset().top + $select.height(); + } else { + // Button wraps around to below inputs; remove any previous vertical offsetting. + buttonOffset.top = fpicOffset.top + $fpic.height() + 10; + } + $button.offset(buttonOffset); + } + + function setAssemblyOptions(uiState) { + var assemblySelectLabel = 'Assembly'; + if (uiState.dbOptions) { + var html = '', option, i, selected; + for (i = 0; i < uiState.dbOptions.length; i++) { + option = uiState.dbOptions[i]; + selected = (option.value === uiState.db) ? 'selected ' : ''; + html += '<option ' + selected + 'value="' + option.value + '">' + + option.label + '</option>'; + } + $('#selectAssembly').html(html); + } + if (uiState.genomeLabel) { + if (uiState.hubUrl && uiState.genomeLabel.indexOf('Hub') < 0) { + assemblySelectLabel = uiState.genomeLabel + ' Hub Assembly'; + } else { + assemblySelectLabel = uiState.genomeLabel + ' Assembly'; + } + } + $('#selectAssemblyLabel').text(assemblySelectLabel); + } + + function trackHubSkipHubName(name) { + // Just like hg/lib/trackHub.c's... + var matches; + if (name && (matches = name.match(/^hub_[0-9]+_(.*)/)) !== null) { + return matches[1]; + } else { + return name; + } + } + + function setAssemblyDescriptionTitle(db, genome) { + $('#descriptionGenome').html(trackHubSkipHubName(genome)); + $('#descriptionDb').html(trackHubSkipHubName(db)); + } + + function insertNamedAnchor(string, ix, anchorName) { + return string.substring(0, ix) + '<a name="' + anchorName + '"></a>' + string.substring(ix); + } + + function linkToNamedAnchor(title, anchor) { + return '<a class="jwAnchor" href="#' + anchor + '">' + + '<i class="fa fa-arrow-right jwAnchorArrow"></i> ' + + title + '</a><br>'; + } + + function addSubsectionLink(section, title, anchor) { + var ix = section.bottom.indexOf(title); + if (ix >= 0) { + section.bottom = insertNamedAnchor(section.bottom, ix, anchor); + section.anchors += linkToNamedAnchor(title, anchor); + } + } + + function digestDescription(description) { + // Search for familiar patterns in our description.html text and if possible, + // break it into a top (summary and photo), anchor section (links to useful + // subsections of details), and bottom (details). + var section = { top: '', anchors: '', bottom: ''}; + var re, matches; + // IE8 can't handle the regex syntax [^] ("not the null character") which matches newlines + // in later versions. So wrap this in a try/catch: + try { + re = new RegExp('([^]*?)<HR>([^]*?Search the assembly[^]*)'); + if (description) { + matches = description.match(re); + if (matches) { + section.top = matches[1]; + section.bottom = matches[2]; + } else { + matches = description.match(/([^]*?)(<H3>Sample position queries<\/H3>[^]*)/i); + if (matches) { + section.top = matches[1]; + section.bottom = matches[2]; + } else { + section.top = description; + } + } + // Make links to subsections (if found): + addSubsectionLink(section, 'Search the assembly', 'searchHelp'); + addSubsectionLink(section, 'Download sequence and annotation data', 'download'); + addSubsectionLink(section, 'Sample Position Queries', 'sampleQueries'); + addSubsectionLink(section, 'Sample position queries', 'sampleQueries'); + addSubsectionLink(section, 'Assembly Details', 'assemblyDetails'); + addSubsectionLink(section, 'Assembly details', 'assemblyDetails'); + addSubsectionLink(section, 'Genbank Pipeline Details', 'genbankDetails'); + } + } + catch (exc) { + section.top = description; + } + return section; + } + + function tweakDescriptionPhotoWidth() { + // Our description.html files assume a pretty wide display area, but now we're + // squeezed to the right of the 'Select Species' section. If there's a large + // image, scale it down. The enclosing table is usually sized to leave a lot + // of space to the left of the image, so shrink that too. + // This must be called *after* #descriptionTextTop is updated with the new content. + var width, scaleFactor, newWidth; + var $table = $('#descriptionTextTop table').first(); + var $img = $('#descriptionTextTop table img').first(); + if ($img.length) { + width = $img.width(); + if (width > 175) { + // Scale to 150px wide, preserving aspect ratio + newWidth = 150; + scaleFactor = newWidth / width; + $img.width(newWidth) + .height($img.height() * scaleFactor); + width = newWidth; + } + if ($table.width() - width > 20) { + $table.width(width + 10); + } + // hg19's description.html sets a height for its table that pushes the + // links section down; unneeded & unwanted here, so remove height if set: + $table.removeAttr('height'); + } + } + + function updateDescription(description) { + // We got the contents of a db's description.html -- tweak its format to fit + // the new design. + var sections = digestDescription(description); + $('#descriptionTextTop').html(sections.top); + $('#descriptionAnchors').html(sections.anchors); + $('#descriptionTextBottom').html(sections.bottom); + if (sections.anchors) { + $('#descriptionAnchors').show(); + } else { + $('#descriptionAnchors').hide(); + } + if (sections.bottom) { + $('#descriptionTextBottom').show(); + } else { + $('#descriptionTextBottom').hide(); + } + tweakDescriptionPhotoWidth(); + // Apply JWest formatting to all anchors in description. + // We can't simply style all <a> tags that way because autocomplete uses <a>'s. + $('#descriptionTextTop a').addClass('jwAnchor'); + $('#descriptionTextBottom a').addClass('jwAnchor'); + // Apply square bullet style to all ul's in description. + $('#descriptionTextTop ul').addClass('jwNoBullet'); + $('#descriptionTextTop li').addClass('jwSquareBullet'); + $('#descriptionTextBottom ul').addClass('jwNoBullet'); + $('#descriptionTextBottom li').addClass('jwSquareBullet'); + } + + function updateFindPositionSection(uiState) { + var suggestUrl = null; + if (uiState.suggestTrack) { + suggestUrl = 'hgSuggest?db=' + uiState.db + '&prefix='; + } + setAssemblyOptions(uiState); + if (uiState.position) { + $('#positionDisplay').text(uiState.position); + } + autocompleteCat.init($('#positionInput'), + { baseUrl: suggestUrl, + watermark: positionWatermark, + onSelect: onSelectGene, + enterSelectsIdentical: true, + onEnterTerm: goToHgTracks }); + updateGoButtonPosition(); + setAssemblyDescriptionTitle(uiState.db, uiState.genome); + updateDescription(uiState.description); + } + + // Server response event handlers + + function checkJsonData(jsonData, callerName) { + // Return true if jsonData isn't empty and doesn't contain an error; + // otherwise complain on behalf of caller. + if (! jsonData) { + alert(callerName + ': empty response from server'); + } else if (jsonData.error) { + console.error(jsonData.error); + alert(callerName + ': error from server: ' + jsonData.error); + } else { + if (debugCartJson) { + console.log('from server:\n', jsonData); + } + return true; + } + return false; + } + + function updateStateAndPage(jsonData) { + // Update uiState with new values and update the page. + var hubsChanged = !_.isEqual(jsonData.hubs, uiState.hubs); + // In rare cases, there can be a genome (e.g. Baboon) with multiple species/taxIds + // (e.g. Papio anubis for papAnu1 vs. Papio hamadryas for papHam1). Changing the + // db can result in changing the taxId too. In that case, update the highlighted + // species in the tree image. + if (jsonData.taxId !== uiState.taxId) { + highlightLabel('textEl_' + jsonData.taxId, false); + } + _.assign(uiState, jsonData); + updateFindPositionSection(uiState); + if (hubsChanged) { + drawSpeciesPicker(); + } + } + + function handleRefreshState(jsonData) { + if (checkJsonData(jsonData, 'handleRefreshState')) { + updateStateAndPage(jsonData); + } + } + + function handleSetDb(jsonData) { + // Handle the server's response to cartJson command setDb or setHubDb + if (checkJsonData(jsonData, 'handleSetDb') && + trackHubSkipHubName(jsonData.db) === trackHubSkipHubName(uiState.db)) { + updateStateAndPage(jsonData); + } else { + console.log('handleSetDb ignoring: ' + trackHubSkipHubName(jsonData.db) + + ' !== ' + trackHubSkipHubName(uiState.db)); + } + } + + function handleSetTaxId(jsonData) { + // Handle the server's response to the setTaxId cartJson command. + if (checkJsonData(jsonData, 'handleSetTaxId') && jsonData.taxId === uiState.taxId) { + // Update uiState with new values and update the page: + _.assign(uiState, jsonData); + updateFindPositionSection(uiState); + } else { + console.log('handleSetTaxId ignoring: ' + jsonData.taxId + + ' !== ' + uiState.taxId); + } + } + + // UI Event Handlers + + function clearWatermarkInput($input, watermark) { + // Note: it is not necessary to re-.Watermark if we upgrade the plugin to version >= 3.1 + $input.val('').Watermark(watermark); + } + + function clearSpeciesInput() { + // Replace anything typed into the species input with the watermark. + clearWatermarkInput($('#speciesSearch'), speciesWatermark); + } + + function clearPositionInput() { + // Replace anything typed into the position input with the watermark. + clearWatermarkInput($('#positionInput'), positionWatermark); + } + + function setTaxId(taxId, db, doScrollToItem, doClearSpeciesInput) { + // The user has selected a species (and possibly even a particular database) -- + // if we're not already using it, change to it. + var cmd; + if (taxId !== uiState.taxId || (db && db !== uiState.db)) { + uiState.taxId = taxId; + uiState.hubUrl = null; + cmd = { setTaxId: { taxId: '' + taxId } }; + if (db) { + uiState.db = db; + cmd.setTaxId.db = db; + } + cart.send(cmd, handleSetTaxId); + cart.flush(); + clearPositionInput(); + } + highlightLabel('textEl_' + taxId, doScrollToItem); + if (doClearSpeciesInput) { + clearSpeciesInput(); + } + } + + function setHubDb(hubUrl, taxId, db, hubName, isAutocomplete) { + // User clicked on a hub name (switch to its default genome) or selected an + // assembly hub from autocomplete (switch to that assembly hub db). + var cmd; + if (hubUrl !== uiState.hubUrl || + (isAutocomplete && db !== uiState.db)) { + uiState.hubUrl = hubUrl; + uiState.taxId = taxId; + uiState.db = trackHubSkipHubName(db); + // Use cart variables to connect to the selected hub and switch to db + // (hubConnectLoadHubs, called by cartNew) + cmd = { cgiVar: { hubUrl: hubUrl, + genome: trackHubSkipHubName(db) }, + setHubDb: { hubUrl: hubUrl, + taxId: '' + taxId } + }; + cart.send(cmd, handleSetDb); + cart.flush(); + clearPositionInput(); + } + highlightLabel('textEl_' + hubName, isAutocomplete); + if (! isAutocomplete) { + clearSpeciesInput(); + } + } + + + function setDbFromAutocomplete(item) { + // The user has selected a result from the species-search autocomplete. + // It might be a taxId and/or db from dbDb, or it might be a hub db. + var taxId = item.taxId || -1; + var db = item.db; + if (item.hubUrl) { + // The autocomplete sends the hub database from hubPublic.dbList, + // without the hub prefix -- restore the prefix here. + db = item.hubName + '_' + item.db; + setHubDb(item.hubUrl, taxId, db, item.hubName, true); + } else { + setTaxId(taxId, item.db, true, false); + } + } + + function onClickSpeciesLabel(taxId) { + // When user clicks on a label, use that taxId (default db); + // don't scroll to the label because if they clicked on it they can see it already; + // do clear the autocomplete input. + setTaxId(taxId, null, false, true); + } + + function onClickHubName(hubUrl, taxId, db, hubName) { + // This is just a wrapper -- the draw module has to know all about the contents + // of each hub object in hubList anyway. + setHubDb(hubUrl, taxId, db, hubName, false); + } + + function onChangeDbMenu() { + // The user selected a different db for this genome; get db info from server. + var db = $('#selectAssembly').val(); + var cmd; + if (db !== uiState.db) { + setAssemblyDescriptionTitle(db, uiState.genome); + cmd = { setDb: { db: db } }; + if (uiState.hubUrl) { + cmd.setDb.hubUrl = uiState.hubUrl; + } + cart.send(cmd, handleSetDb); + cart.flush(); + uiState.db = db; + clearPositionInput(); + } + } + + function onClickCopyPosition() { + // Copy the displayed position into the position input: + var posDisplay = $('#positionDisplay').text(); + $('#positionInput').val(posDisplay); + } + + function goToHgTracks() { + // Create and submit a form for hgTracks with hidden inputs for org, db and position. + var position = $('#positionInput').val(); + var posDisplay = $('#positionDisplay').text(); + var pix = uiState.pix || calculateHgTracksWidth(); + var $form; + if (! position || position === '' || position === positionWatermark) { + position = posDisplay; + } + // Show a spinner -- sometimes it takes a while for hgTracks to start displaying. + $('.jwGoIcon').removeClass('fa-play').addClass('fa-spinner fa-spin'); + // Make a form and submit it. In order for this to work in IE, the form + // must be appended to the body. + $form = $('<form action="hgTracks" method=GET id="mainForm">' + + '<input type=hidden name="org" value="' + uiState.genome + '">' + + '<input type=hidden name="db" value="' + uiState.db + '">' + + '<input type=hidden name="position" value="' + position + '">' + + '<input type=hidden name="pix" value="' + pix + '">' + + '</form>'); + $('body').append($form); + $form.submit(); + } + + function replaceHgsidInLinks() { + // Substitute '$hgsid' with real hgsid in <a> href's. + $('a').each(function(ix, aEl) { + var href = aEl.getAttribute('href'); + if (href && href.indexOf('$hgsid') >= 0) { + aEl.setAttribute('href', href.replace('$hgsid', window.hgsid)); + } + }); + } + + function init() { + // Boot up the page; initialize elements and install event handlers. + cart.setCgi('hgGateway'); + cart.debug(debugCartJson); + // Get state from cart + cart.send({ getUiState: {} }, handleRefreshState); + cart.flush(); + + // When page has loaded, draw the species tree, do layout adjustments and + // initialize event handlers. + $(function() { + var searchObj = autocompleteFromTree(dbDbTree); + addAutocompleteCommonNames(searchObj); + scrollbarWidth = findScrollbarWidth(); + drawSpeciesPicker(); + setRightColumnWidth(); + setupFavIcons(); + autocompleteCat.init($('#speciesSearch'), + { baseUrl: 'hgGateway?hggw_term=', + watermark: speciesWatermark, + onSelect: setDbFromAutocomplete, + searchObj: searchObj, + enterSelectsIdentical: true }); + updateFindPositionSection(uiState); + $('#selectAssembly').change(onChangeDbMenu); + $('#positionDisplay').click(onClickCopyPosition); + $('#copyPosition').click(onClickCopyPosition); + $('.jwGoButtonContainer').click(goToHgTracks); + $(window).resize(setRightColumnWidth.bind(null, scrollbarWidth)); + $(window).resize(updateGoButtonPosition); + replaceHgsidInLinks(); + }); + } + + return { init: init, + // For use by speciesTree.draw SVG (text-only onclick): + onClickSpeciesLabel: onClickSpeciesLabel, + onClickHubName: onClickHubName + }; + +}()); // hgGateway + +hgGateway.init();