diff --git a/app/controllers/explore.coffee b/app/controllers/explore.coffee index 49a36a6..e9505c5 100644 --- a/app/controllers/explore.coffee +++ b/app/controllers/explore.coffee @@ -8,24 +8,25 @@ L = require 'zooniverse/vendor/leaflet/leaflet-src' moment = require('moment/moment') animals = require('lib/animals') + class Explore extends Controller className: 'explore' dateGranularity: 10 - cartoTable: 'serengeti_random' + cartoTable: 'serengeti' layers: [] styles: ['#d32323', '#525b46'] - dateFmt: 'DD MMM YYYY, hh:mm A' + dateFmt: 'DD MMM YYYY' maxCache: 10 cache: [] events: - 'change select.filter' : 'onSpeciesSelect' 'click input[name="scope"]' : 'setUserScope' - 'click button.species' : 'onPickSpecies' + 'click button.species' : 'showAnimalMenu' + 'click div[data-animal]' : 'setSpecies' 'mouseleave .animals' : 'hideAnimalMenu' - 'click div[data-animal]' : 'onSpeciesSelect' + 'change .legend input' : 'toggleLayer' elements: '.sign-in' : 'signInContainer' @@ -59,33 +60,121 @@ class Explore extends Controller centerOffset: [0, 0] zoom: 11 className: 'full-screen' + + # Set bounds for the map + southWest = new L.LatLng(-3, 34) + northEast = new L.LatLng(-2, 36) + bounds = new L.LatLngBounds(southWest, northEast) + @map.map.setMaxBounds(bounds) @map.el.appendTo @el.find('.map-container') + # Create a custom layer + @terrainLayer = L.tileLayer("/tiles/{z}/{x}/{y}.png", + minZoom: 7 + maxZoom: 12 + attribution: 'Natural Earth (http://www.naturalearthdata.com/)' + noWrap: true + ) + @terrainLayer.addTo @map.map + # Append div for showing date range @el.find('.map-container .map').prepend("
") @dateEl = @el.find('.map-container .map .dates') + # Create legend + @el.find('.map-container .map').prepend("
") + @legendEl = @el.find('.map-container .map .legend') + @legendEl.append("
") + @legendEl.append("") + + # Set date range + @startDate = moment('01 Jul 2010, 00:00 PM+02:00') + endDate = moment('01 Apr 2012, 00:00 PM+02:00') + @interval = endDate.diff(@startDate) / @dateGranularity + + @initCartoDBLayer() + @navButtons.first().click() @onUserSignIn() - + onUserSignIn: => @el.toggleClass 'signed-in', !!User.current + @my.removeAttr('disabled') # enable 'My Classifications' + + initCartoDBLayer: => + query1 = "WITH hgrid AS (SELECT CDB_HexagonGrid(ST_Expand(CDB_XYZ_Extent({x},{y},{z}),CDB_XYZ_Resolution({z}) * 15),CDB_XYZ_Resolution({z}) * 15 ) as cell) SELECT hgrid.cell as the_geom_webmercator, avg(i.how_many) as prop_count FROM hgrid, serengeti i WHERE i.species = 'zebra' AND ST_Intersects(i.the_geom_webmercator, hgrid.cell) GROUP BY hgrid.cell" + query2 = "WITH hgrid AS (SELECT CDB_HexagonGrid(ST_Expand(CDB_XYZ_Extent({x},{y},{z}),CDB_XYZ_Resolution({z}) * 15),CDB_XYZ_Resolution({z}) * 15 ) as cell) SELECT hgrid.cell as the_geom_webmercator, avg(i.how_many) as prop_count FROM hgrid, serengeti i WHERE i.species = 'lionFemale' AND ST_Intersects(i.the_geom_webmercator, hgrid.cell) GROUP BY hgrid.cell" - # Enable 'My Classifications' - @my.removeAttr('disabled') - if User.current - @requestDateRange() - # @requestSpecies(0, 1) + style1 = ''' + #serengeti { + polygon-opacity:0.6; + line-color: #FFF; + line-opacity: 0.7; + line-width: 1; + [prop_count < 1.5] {polygon-fill:#FFF7F3;} + [prop_count < 2.0] {polygon-fill:#FDE0DD;} + [prop_count < 2.5] {polygon-fill:#FCC5C0;} + [prop_count < 3.0] {polygon-fill:#FA9FB5;} + [prop_count < 3.5] {polygon-fill:#F768A1;} + [prop_count < 4.0] {polygon-fill:#DD3497;} + [prop_count < 4.5] {polygon-fill:#AE017E;} + [prop_count < 5.0] {polygon-fill:#7A0177;} + [prop_count > 5.0] {polygon-fill:#49006A;} + } + ''' + + style2 = ''' + #serengeti { + polygon-opacity:0.4; + line-color: #FFF; + line-opacity: 0.7; + line-width: 1; + [prop_count<1.5] {polygon-fill:#F0F9E8;} + [prop_count<3.0] {polygon-fill:#BAE4BC;} + [prop_count<4.5] {polygon-fill:#7BCCC4;} + [prop_count<6.0] {polygon-fill:#43A2CA;} + [prop_count>6.0] {polygon-fill:#0868AC;} + } + ''' + + @cartoLayer1 = new L.CartoDBLayer({ + map: @map.map + user_name: 'the-zooniverse' + table_name: @cartoTable + infowindow: false + tile_style: style1 + query: query1 + interactivity: false + auto_bound: false + debug: false + }) + @cartoLayer2 = new L.CartoDBLayer({ + map: @map.map + user_name: 'the-zooniverse' + table_name: @cartoTable + infowindow: false + tile_style: style2 + query: query2 + interactivity: false + auto_bound: false + debug: false + }) + @map.map.addLayer(@cartoLayer1) + @map.map.addLayer(@cartoLayer2) - setUserScope: (e) -> - @requestDateRange() - @onSpeciesSelect() + setUserScope: -> @setSpecies() - onPickSpecies: (e) => - @speciesIndex = e.target.dataset.index + toggleLayer: (e) => + index = e.target.dataset.index + if @el.find("#layer#{index}:checked").length is 0 + @["cartoLayer#{index}"].hide() + else + @["cartoLayer#{index}"].show() + + showAnimalMenu: (e) => + index = e.target.dataset.index + @animalMenu.attr('data-index', index) @animalMenu.addClass('active') - position = if @speciesIndex is '1' then 'left' else 'right' - @animalMenu.attr('data-position', position) hideAnimalMenu: => @animalMenu.removeClass('active') @@ -95,24 +184,34 @@ class Explore extends Controller value = ui.value @requestSpecies(value, value + 1) - onSpeciesSelect: (e) => + setSpecies: (e) => if e? + console.log "animal = ", e.target.dataset.animal or e.target.parentElement.dataset.animal + + @hideAnimalMenu() + + target = e.target species = e.target.innerText - @["species#{@speciesIndex}"] = e.target.dataset.animal or '' + index = target.parentElement.dataset.index or target.parentElement.parentElement.dataset.index + + @["species#{index}"] = target.dataset.animal or target.parentElement.dataset.animal + # Swap text in button - $("button.species[data-index='#{@speciesIndex}']").text(species) - @hideAnimalMenu() - + $("button.species[data-index='#{index}']").text(species) + $("span.animal-name[data-index='#{index}']").text(species) + @el.find('.legend').css('opacity', 1) + value = @dateSlider.slider('option', 'value') - @requestSpecies(value, value + 1) + @updateCartoQuery(index, @["species#{index}"], value) updateDateRange: => n = @dateSlider.slider('option', 'value') start = @startDate.clone().add('ms', n * @interval).format(@dateFmt) end = @startDate.clone().add('ms', (n + 1) * @interval).format(@dateFmt) - $('.map-container .map .dates').html("#{start} — #{end} (East Africa Time)") + $('.map-container .map .dates').html("#{start} — #{end}") getQueryUrl: (query) -> + console.log query url = encodeURI "http://the-zooniverse.cartodb.com/api/v2/sql?q=#{query}" return url.replace(/\+/g, '%2B') # Must manually escape plus character (maybe others too) @@ -150,41 +249,32 @@ class Explore extends Controller # Methods for querying CartoDB # - # Request the minimum and maximum dates of image capture - requestDateRange: => - query = "SELECT MIN(captured_at), MAX(captured_at) FROM #{@cartoTable}" - if $('input[name="scope"]:checked').val() is 'my' - query += " WHERE user_id = '#{User.current.id}'" + updateCartoQuery: (index, species, startTimeIndex) => + query = "WITH hgrid AS (SELECT CDB_HexagonGrid(ST_Expand(CDB_XYZ_Extent({x},{y},{z}),CDB_XYZ_Resolution({z}) * 15),CDB_XYZ_Resolution({z}) * 15 ) as cell) SELECT hgrid.cell as the_geom_webmercator, avg(i.how_many) as prop_count FROM hgrid, serengeti i WHERE i.species = '#{species}' AND ST_Intersects(i.the_geom_webmercator, hgrid.cell) GROUP BY hgrid.cell" - url = @getQueryUrl(query) - $.ajax({url: url, beforeSend: @ajaxStart}) - .done(@getDateRange) - .then(@ajaxStop) - # .then(@requestSpecies) - .fail( (e) -> alert 'Sorry, the query failed') + @["cartoLayer#{index}"].setQuery(query) # Request species counts for all sites between a date interval - requestSpecies: => + requestSpecies: (n1, n2) => start = @startDate.clone() end = @startDate.clone() # Get start and end date and update ui - n = @dateSlider.slider('option', 'value') - start = @startDate.clone().add('ms', n * @interval).format(@dateFmt) - end = @startDate.clone().add('ms', (n + 1) * @interval).format(@dateFmt) - $('.map-container .map .dates').html("#{start} — #{end} (East Africa Time)") + start = @startDate.clone().add('ms', n1 * @interval).format(@dateFmt) + end = @startDate.clone().add('ms', n2 * @interval).format(@dateFmt) + $('.map-container .map .dates').html("#{start} — #{end}") query = """ - SELECT ST_AsGeoJSON(the_geom) as the_geom, species, AVG(how_many), site_roll_code - FROM serengeti_random + SELECT cartodb_id, ST_AsGeoJSON(the_geom_webmercator) as the_geom_webmercator, species, AVG(how_many), site_roll_code + FROM #{@cartoTable} WHERE (species = '#{@species1}' OR species = '#{@species2}') """ if $('input[name="scope"]:checked').val() is 'my' query += " AND (user_id = '#{User.current.id}') " query += """ - AND (captured_at BETWEEN '#{start}+02:00' AND '#{end}+02:00') - GROUP BY the_geom, species, site_roll_code + AND (captured_at BETWEEN '#{start}+02:00' AND '#{end}+02:00') + GROUP BY the_geom_webmercator, species, site_roll_code """ url = @getQueryUrl(query) @@ -211,18 +301,6 @@ class Explore extends Controller # Methods for receiving query results from CartoDB # - getDateRange: (response) => - result = response.rows[0] - - @startDate = moment(result.min) - endDate = moment(result.max) - @interval = endDate.diff(@startDate) / @dateGranularity - - n = @dateSlider.slider('option', 'value') - start = @startDate.clone().add('ms', n * @interval).format(@dateFmt) - end = @startDate.clone().add('ms', (n + 1) * @interval).format(@dateFmt) - $('.map-container .map .dates').html("#{start} — #{end} (East Africa Time)") - getSpecies: (rows) => cross = crossfilter(rows) @@ -240,39 +318,26 @@ class Explore extends Controller @layers = [] for species, index in [species1, species2] + # heatmap = [] for row in species avg = row.avg [lng, lat] = row.the_geom.coordinates + # heatmap.push {lat: lat, lon: lng, value: avg} # Create two circles over each other - outerCircle = L.circle([lat, lng], 200 * avg, { + circle = L.circle([lat, lng], @getRadius(avg), { fillColor: @styles[index] - fillOpacity: 0.01 * Math.exp(avg / 4) - stroke: false + fillOpacity: @getOpacity(avg) + color: @styles[index] + stroke: true + opacity: 0.7 + weight: 0 }) - # innerCircle = L.circle([lat, lng], 100 * avg, { - # fillColor: @styles[index] - # fillOpacity: 0.25 - # stroke: false - # }) - - @layers.push outerCircle - # @layers.push innerCircle - @map.map.addLayer(outerCircle) - # @map.map.addLayer(innerCircle) - # for species, index in [species1, species2] - # for row in species - # avg = row.avg - # [lng, lat] = row.the_geom.coordinates - # - # circle = L.circle([lat, lng], 10 * avg, { - # color: @styles[index] - # fillColor: @styles[index], - # fillOpacity: 0.5 - # }) - # @layers.push circle - # @map.map.addLayer(circle) - - + @layers.push circle + @map.map.addLayer(circle) + + getRadius: (x) -> return 1600 * (-1 + 2 / (1 + Math.exp(-2 * 0.25 * x))) + getOpacity: (x) -> return 0.5 * (-1 + 2 / (1 + Math.exp(-2 * x))) + module.exports = Explore diff --git a/app/lib/cartodb-leaflet.js b/app/lib/cartodb-leaflet.js new file mode 100644 index 0000000..88b3e6b --- /dev/null +++ b/app/lib/cartodb-leaflet.js @@ -0,0 +1,806 @@ +/** +* @name cartodb-leaflet +* @version 0.55 [October 31, 2012] +* @author: Vizzuality.com +* @fileoverview Author: Vizzuality.com
Licence: +* Licensed under MIT +* license.
This library lets you to use CartoDB with Leaflet. +* +*/ + +L = require('zooniverse/vendor/leaflet/leaflet-src'); + +if (typeof(L.CartoDBLayer) === "undefined") { + + L.CartoDBLayer = L.Class.extend({ + + version: "0.55", + + includes: L.Mixin.Events, + + options: { + query: "SELECT * FROM {{table_name}}", + opacity: 0.99, + auto_bound: false, + attribution: "CartoDB", + debug: false, + visible: true, + added: false, + tiler_domain: "cartodb.com", + tiler_port: "80", + tiler_protocol: "http", + sql_domain: "cartodb.com", + sql_port: "80", + sql_protocol: "http", + extra_params: {}, + cdn_url: null, + subdomains: "abc" + }, + + /** + * Initialize CartoDB Layer + * @params {Object} + * map - Your Leaflet map + * user_name - CartoDB user name + * table_name - CartoDB table name + * query - If you want to apply any sql sentence to the table... + * opacity - If you want to change the opacity of the CartoDB layer + * tile_style - If you want to add other style to the layer + * interactivity - Get data from the feature clicked ( without any request :) ) + * featureOver - Callback when user hovers a feature (return mouse event, latlng, position (x & y) and feature data) + * featureOut - Callback when user hovers out a feature + * featureClick - Callback when user clicks a feature (return mouse/touch event, latlng, position (x & y) and feature data) + * attribution - Set the attribution text + * debug - Get error messages from the library + * auto_bound - Let cartodb auto-bound-zoom in the map (opcional - default = false) + * + * tiler_protocol - Tiler protocol (opcional - default = 'http') + * tiler_domain - Tiler domain (opcional - default = 'cartodb.com') + * tiler_port - Tiler port as a string (opcional - default = '80') + * sql_protocol - SQL API protocol (opcional - default = 'http') + * sql_domain - SQL API domain (opcional - default = 'cartodb.com') + * sql_port - SQL API port as a string (opcional - default = '80') + * extra_params - In case you want to pass aditional params to cartodb tiler, pass them + * as an object + * cdn_url - If you want to use a CDN as a proxy set the URL + */ + + initialize: function (options) { + // Set options + L.Util.setOptions(this, options); + + // Some checks + if (!options.table_name || !options.map) { + if (options.debug) { + throw('cartodb-leaflet needs at least a CartoDB table name and the Leaflet map object :('); + } else { return } + } + + // Bounds? CartoDB does it + if (options.auto_bound) + this.setBounds(); + + // Add cartodb logo, yes sir! + this._addWadus(); + }, + + + /** + * When Leaflet adds the layer... go! + * @params {map} + */ + onAdd: function(map) { + this._addLayer(); + this.fire('added'); + this.options.added = true; + }, + + + /** + * When removes the layer, destroy interactivity if exist + */ + onRemove: function(map) { + this._remove(); + this.options.added = false; + }, + + + /** + * Change opacity of the layer + * @params {Integer} New opacity + */ + setOpacity: function(opacity) { + + if (!this.options.added) { + if (this.options.debug) { + throw('the layer is not still added to the map'); + } else { return } + } + + if (isNaN(opacity) || opacity>1 || opacity<0) { + if (this.options.debug) { + throw(opacity + ' is not a valid value'); + } else { return } + } + + // Leaflet only accepts 0-0.99... Weird! + this.options.opacity = opacity; + + if (this.options.visible) { + this.layer.setOpacity(opacity == 1 ? 0.99 : opacity); + this.fire('updated'); + } + }, + + + /** + * Change query of the tiles + * @params {str} New sql for the tiles + */ + setQuery: function(sql) { + + if (!this.options.added) { + if (this.options.debug) { + throw('the layer is not still added to the map'); + } else { return } + } + + if (!isNaN(sql)) { + if (this.options.debug) { + throw(sql + ' is not a valid query'); + } else { return } + } + + // Set the new value to the layer options + this.options.query = sql; + this._update(); + }, + + + /** + * Change style of the tiles + * @params {style} New carto for the tiles + */ + setStyle: function(style) { + + if (!this.options.added) { + if (this.options.debug) { + throw('the layer is not still added to the map'); + } else { return } + } + + if (!isNaN(style)) { + if (this.options.debug) { + throw(style + ' is not a valid style'); + } else { return } + } + + // Set the new value to the layer options + this.options.tile_style = style; + this._update(); + }, + + + /** + * Change the query when clicks in a feature + * @params {Boolean | String} New sql for the request + */ + setInteractivity: function(value) { + + if (!this.options.added) { + if (this.options.debug) { + throw('the layer is not still added to the map'); + } else { return } + } + + if (!isNaN(value)) { + if (this.options.debug) { + throw(value + ' is not a valid setInteractivity value'); + } else { return } + } + + // Set the new value to the layer options + this.options.interactivity = value; + // Update tiles + this._update(); + }, + + + /** + * Change layer index + * @params {Integer} New position for the layer + */ + setLayerOrder: function(position) { + /* + Waiting fot this ticket: + https://github.com/CloudMade/Leaflet/issues/505 + */ + }, + + + /** + * Active or desactive interaction + * @params {Boolean} Choose if wants interaction or not + */ + setInteraction: function(bool) { + + if (!this.options.added) { + if (this.options.debug) { + throw('the layer is not still added to the map'); + } else { return } + } + + if (bool !== false && bool !== true) { + if (this.options.debug) { + throw(bool + ' is not a valid setInteraction value'); + } else { return } + } + + if (this.interaction) { + if (bool) { + var self = this; + this.interaction.on('on', function(o) {self._bindWaxOnEvents(self.options.map,o)}); + this.interaction.on('off', function(o) {self._bindWaxOffEvents()}); + } else { + this.interaction.off('on'); + this.interaction.off('off'); + } + } + }, + + + /** + * Set a new layer attribution + * @params {String} New attribution string + */ + setAttribution: function(attribution) { + + if (!this.options.added) { + if (this.options.debug) { + throw('the layer is not still added to the map'); + } else { return } + } + + if (!isNaN(attribution)) { + if (this.options.debug) { + throw(attribution + ' is not a valid attribution'); + } else { return } + } + + // Remove old one + this.options.map.attributionControl.removeAttribution(this.options.attribution); + + // Set new attribution in the options + this.options.attribution = attribution; + + // Change text + this.options.map.attributionControl.addAttribution(this.options.attribution); + + // Change in the layer + this.layer.options.attribution = this.options.attribution; + this.tilejson.attribution = this.options.attribution; + + this.fire('updated'); + }, + + + /** + * Change multiple options at the same time + * @params {Object} New options object + */ + setOptions: function(options) { + + if (!this.options.added) { + if (this.options.debug) { + throw('the layer is not still added to the map'); + } else { return } + } + + if (typeof options!= "object" || options.length) { + if (this.options.debug) { + throw(options + ' options has to be an object'); + } else { return } + } + + // Set options + L.Util.setOptions(this, options); + + // Update tiles + this._update(); + }, + + + /** + * Returns if the layer is visible or not + */ + isVisible: function() { + return this.options.visible + }, + + + /** + * Returns if the layer belongs to the map + */ + isAdded: function() { + return this.options.added + }, + + + /** + * Hide the CartoDB layer + */ + hide: function() { + + if (!this.options.added) { + if (this.options.debug) { + throw('the layer is not still added to the map'); + } else { return } + } + + if (!this.options.visible) { + if (this.options.debug) { + throw('the layer is already hidden'); + } else { return } + } + + this.layer.setOpacity(0); + this.setInteraction(false); + this.options.visible = false; + this.fire('hidden'); + }, + + + /** + * Show the CartoDB layer + */ + show: function() { + + if (!this.options.added) { + if (this.options.debug) { + throw('the layer is not still added to the map'); + } else { return } + } + + if (this.options.visible) { + if (this.options.debug) { + throw('the layer is already shown'); + } else { return } + } + + this.layer.setOpacity(this.options.opacity); + this.setInteraction(true); + this.options.visible = true; + this.fire('shown'); + }, + + + + /* + * PRIVATE METHODS + */ + + + /** + * Remove CartoDB layer + */ + _remove: function() { + // Unbind interaction + this.setInteraction(false); + + // Remove bind loading and load events + this.layer + .off("loading") + .off("load") + + // Remove interacion + if (this.interaction) + this.interaction.remove(); + + // Remove layer + this.options.map.removeLayer(this.layer); + + this.fire('removed'); + }, + + + /** + * Update CartoDB layer + */ + _update: function() { + // First remove old layer + this._remove(); + + // Create the new updated one + this._addLayer(); + + this.fire('updated'); + }, + + + /** + * Zoom to cartodb geometries + */ + setBounds: function(sql) { + var self = this + , query = ""; + + if (sql) { + // Custom query + query = sql; + } else { + // Already defined query + query = this.options.query; + } + + reqwest({ + url: this._generateCoreUrl("sql") + '/api/v2/sql/?q='+escape('SELECT ST_XMin(ST_Extent(the_geom)) as minx,ST_YMin(ST_Extent(the_geom)) as miny,'+ + 'ST_XMax(ST_Extent(the_geom)) as maxx,ST_YMax(ST_Extent(the_geom)) as maxy from ('+ query.replace(/\{\{table_name\}\}/g,this.options.table_name) + ') as subq'), + type: 'jsonp', + jsonpCallback: 'callback', + success: function(result) { + if (result.rows[0].maxx!=null) { + var coordinates = result.rows[0]; + + var lon0 = coordinates.maxx; + var lat0 = coordinates.maxy; + var lon1 = coordinates.minx; + var lat1 = coordinates.miny; + + var minlat = -85.0511; + var maxlat = 85.0511; + var minlon = -179; + var maxlon = 179; + + /* Clamp X to be between min and max (inclusive) */ + var clampNum = function(x, min, max) { + return x < min ? min : x > max ? max : x; + } + + lon0 = clampNum(lon0, minlon, maxlon); + lon1 = clampNum(lon1, minlon, maxlon); + lat0 = clampNum(lat0, minlat, maxlat); + lat1 = clampNum(lat1, minlat, maxlat); + + var sw = new L.LatLng(lat0, lon0); + var ne = new L.LatLng(lat1, lon1); + var bounds = new L.LatLngBounds(sw,ne); + self.options.map.fitBounds(bounds); + } + }, + error: function(e,msg) { + if (this.options.debug) throw('Error getting table bounds: ' + msg); + } + }); + }, + + + /** + * Add Cartodb logo + */ + _addWadus: function() { + if (!document.getElementById('cartodb_logo')) { + var cartodb_link = document.createElement("a"); + cartodb_link.setAttribute('id','cartodb_logo'); + cartodb_link.setAttribute('style',"position:absolute; bottom:8px; left:8px; display:block; z-index:10000;"); + cartodb_link.setAttribute('href','http://www.cartodb.com'); + cartodb_link.setAttribute('target','_blank'); + cartodb_link.innerHTML = "CartoDB"; + this.options.map._container.appendChild(cartodb_link); + } + }, + + + /** + * Add interaction cartodb tiles to the map + */ + _addLayer: function () { + + var self = this; + + // generate the tilejson + this.tilejson = this._generateTileJson(); + this.layer = new wax.leaf.connector( + this.tilejson + ).on("loading", function() { + self.fire("loading", this); + }).on("load", function() { + self.fire("load", this); + }); + + // check the tiles + this._checkTiles(); + + // add the layer to the map + this.options.map.addLayer(this.layer,false); + + // add the interaction? + if (this.options.interactivity) { + this.interaction = wax.leaf.interaction() + .map(this.options.map) + .tilejson(this.tilejson) + .on('on', function(o) {self._bindWaxOnEvents(self.options.map,o)}) + .on('off', function(o) {self._bindWaxOffEvents()}); + } + }, + + + /** + * Bind events for wax interaction + * @param {Object} Layer map object + * @param {Event} Wax event + */ + _bindWaxOnEvents: function(map,o) { + var layer_point = this._findPos(map,o) + , latlng = map.layerPointToLatLng(layer_point); + + switch (o.e.type) { + case 'mousemove': if (this.options.featureOver) { + return this.options.featureOver(o.e,latlng,{x: o.e.clientX, y: o.e.clientY},o.data); + } else { + if (this.options.debug) throw('featureOver function not defined'); + } + break; + case 'click': if (this.options.featureClick) { + this.options.featureClick(o.e,latlng,{x: o.e.clientX, y: o.e.clientY},o.data); + } else { + if (this.options.debug) throw('featureClick function not defined'); + } + break; + case 'touchend': if (this.options.featureClick) { + this.options.featureClick(o.e,latlng,{x: o.e.clientX, y: o.e.clientY},o.data); + } else { + if (this.options.debug) throw('featureClick function not defined'); + } + break; + default: break; + } + }, + + + /** + * Bind off event for wax interaction + */ + _bindWaxOffEvents: function(){ + if (this.options.featureOut) { + return this.options.featureOut && this.options.featureOut(); + } else { + if (this.options.debug) throw('featureOut function not defined'); + } + }, + + /** + * Generate tilejson for wax + * @return {Object} Options for L.TileLayer + */ + _generateTileJson: function() { + + var urls = this._generateTileUrls(); + var grids_url = urls.grid_url; + + if (urls.grid_url.indexOf("{s}") != -1) { + + grids_url = []; + + var subdomains = this.options.subdomains; + + if (Object.prototype.toString.call( subdomains ) !== '[object Array]' ) { + subdomains.split(''); + } + + for (var i = 0; i < subdomains.length; i++) { + grids_url.push(urls.grid_url.replace(/\{s\}/g, subdomains[i])); + } + } + + // Build up the tileJSON + return { + blankImage: '../img/blank_tile.png', + tilejson: '1.0.0', + scheme: 'xyz', + attribution: this.options.attribution, + tiles: [urls.tile_url], + grids: grids_url, + tiles_base: urls.tile_url, + grids_base: grids_url, + opacity: this.options.opacity, + formatter: function(options, data) { + return data + } + }; + }, + + + + + /* + * HELPER FUNCTIONS + */ + + + /** + * Parse URI + * @params {String} Tile url + * @return {String} URI parsed + */ + _parseUri: function (str) { + var o = { + strictMode: false, + key: ["source","protocol","authority","userInfo","user","password","host","port","relative","path","directory","file","query","anchor"], + q: { + name: "queryKey", + parser: /(?:^|&)([^&=]*)=?([^&]*)/g + }, + parser: { + strict: /^(?:([^:\/?#]+):)?(?:\/\/((?:(([^:@]*)(?::([^:@]*))?)?@)?([^:\/?#]*)(?::(\d*))?))?((((?:[^?#\/]*\/)*)([^?#]*))(?:\?([^#]*))?(?:#(.*))?)/, + loose: /^(?:(?![^:@]+:[^:@\/]*@)([^:\/?#.]+):)?(?:\/\/)?((?:(([^:@]*)(?::([^:@]*))?)?@)?([^:\/?#]*)(?::(\d*))?)(((\/(?:[^?#](?![^?#\/]*\.[^?#\/.]+(?:[?#]|$)))*\/?)?([^?#\/]*))(?:\?([^#]*))?(?:#(.*))?)/ + } + }, + m = o.parser[o.strictMode ? "strict" : "loose"].exec(str), + uri = {}, + i = 14; + + while (i--) uri[o.key[i]] = m[i] || ""; + + uri[o.q.name] = {}; + uri[o.key[12]].replace(o.q.parser, function ($0, $1, $2) { + if ($1) uri[o.q.name][$1] = $2; + }); + return uri; + }, + + + /** + * Appends callback onto urls regardless of existing query params + * @params {String} Tile url + * @params {String} Tile data + * @return {String} Tile url parsed + */ + _addUrlData: function (url, data) { + url += (this._parseUri(url).query) ? '&' : '?'; + return url += data; + }, + + + /** + * Generate the core URL for the tiler + * @params {String} Options including tiler_protocol, user_name, tiler_domain and tiler_port + */ + _generateCoreUrl: function(type){ + //First check if we are using a CDN which in that case we dont need to do all this. + if (this.options.cdn_url) { + return this.options.cdn_url; + } + + if (type == "sql") { + return this.options.sql_protocol + + "://" + ((this.options.user_name)?this.options.user_name+".":"") + + this.options.sql_domain + + ((this.options.sql_port != "") ? (":" + this.options.sql_port) : ""); + } else { + return this.options.tiler_protocol + + "://" + ((this.options.user_name)?this.options.user_name+".":"") + + this.options.tiler_domain + + ((this.options.tiler_port != "") ? (":" + this.options.tiler_port) : ""); + } + }, + + + /** + * Generate the final tile and grid URLs for the tiler + */ + _generateTileUrls: function() { + var core_url = this._generateCoreUrl("tiler") + , base_url = core_url + '/tiles/' + this.options.table_name + '/{z}/{x}/{y}' + , tile_url = base_url + '.png' + , grid_url = base_url + '.grid.json'; + + // SQL? + if (this.options.query) { + var q = encodeURIComponent(this.options.query.replace(/\{\{table_name\}\}/g,this.options.table_name)); + q = q.replace(/%7Bx%7D/g,"{x}").replace(/%7By%7D/g,"{y}").replace(/%7Bz%7D/g,"{z}"); + var query = 'sql=' + q + tile_url = this._addUrlData(tile_url, query); + grid_url = this._addUrlData(grid_url, query); + } + + // EXTRA PARAMS? + for (_param in this.options.extra_params) { + tile_url = this._addUrlData(tile_url, _param+"="+this.options.extra_params[_param]); + grid_url = this._addUrlData(grid_url, _param+"="+this.options.extra_params[_param]); + } + + // STYLE? + if (this.options.tile_style) { + var style = 'style=' + encodeURIComponent(this.options.tile_style.replace(/\{\{table_name\}\}/g,this.options.table_name)); + tile_url = this._addUrlData(tile_url, style); + grid_url = this._addUrlData(grid_url, style); + } + + // INTERACTIVITY? + if (this.options.interactivity) { + var interactivity = 'interactivity=' + encodeURIComponent(this.options.interactivity.replace(/ /g,'')); + tile_url = this._addUrlData(tile_url, interactivity); + grid_url = this._addUrlData(grid_url, interactivity); + } + + return { + core_url: core_url, + base_url: base_url, + tile_url: tile_url, + grid_url: grid_url + } + }, + + + + /** + * Get the Leaflet Point of the event + * @params {Object} Map object + * @params {Object} Wax event object + */ + _findPos: function (map,o) { + var curleft = curtop = 0; + var obj = map._container; + + + if (obj.offsetParent) { + // Modern browsers + do { + curleft += obj.offsetLeft; + curtop += obj.offsetTop; + } while (obj = obj.offsetParent); + return map.containerPointToLayerPoint(new L.Point((o.e.clientX || o.e.changedTouches[0].clientX) - curleft,(o.e.clientY || o.e.changedTouches[0].clientY) - curtop)) + } else { + // IE + return map.mouseEventToLayerPoint(o.e) + } + }, + + /** + * Check the tiles + */ + _checkTiles: function() { + var xyz = { + z: 4, + x: 6, + y: 6 + }, + self = this, + img = new Image(), + urls = this._generateTileUrls() + + // Choose a x-y-z for the check tile - grid + urls.tile_url = urls.tile_url.replace(/\{z\}/g, xyz.z).replace(/\{x\}/g, xyz.x).replace(/\{y\}/g, xyz.y); + urls.grid_url = urls.grid_url.replace(/\{z\}/g, xyz.z).replace(/\{x\}/g, xyz.x).replace(/\{y\}/g, xyz.y); + + reqwest({ + method: "get", + url: urls.grid_url.replace(/\{s\}/g, "a"), + type: 'jsonp', + jsonpCallback: 'callback', + jsonpCallbackName: 'grid', + success: function() { + clearTimeout(timeout) + }, + error: function(error, msg) { + if (self.interaction) self.interaction.remove(); + + if (self.options.debug) throw ('There is an error in your query or your interaction parameter'); + + self.fire("layererror", msg); + } + }); + + // Hacky for reqwest, due to timeout doesn't work very well + var timeout = setTimeout(function() { + clearTimeout(timeout); + + if (self.options.debug) throw ('There is an error in your query or your interaction parameter'); + + self.fire("layererror", "There is a problem in your SQL or interaction parameter"); + }, 2000); + } + + }); +} \ No newline at end of file diff --git a/app/lib/setup.coffee b/app/lib/setup.coffee index ccf72b5..1be8c75 100644 --- a/app/lib/setup.coffee +++ b/app/lib/setup.coffee @@ -5,4 +5,7 @@ require 'jqueryify' require 'spine' # Here so that it is included after jQuery -require 'lib/jquery-ui-1.9.2.custom' \ No newline at end of file +require 'lib/jquery-ui-1.9.2.custom' +require 'lib/heatmap-leaflet' +require 'lib/wax.leaf' +require 'lib/cartodb-leaflet' \ No newline at end of file diff --git a/app/lib/wax.leaf.js b/app/lib/wax.leaf.js new file mode 100755 index 0000000..a2db1f3 --- /dev/null +++ b/app/lib/wax.leaf.js @@ -0,0 +1,3332 @@ +/* wax - 6.4.0 - v6.0.4-28-g4d63117 */ + +!function (name, context, definition) { + if (typeof module !== 'undefined') module.exports = definition(name, context); + else if (typeof define === 'function' && typeof define.amd === 'object') define(definition); + else context[name] = definition(name, context); +}('bean', this, function (name, context) { + var win = window + , old = context[name] + , overOut = /over|out/ + , namespaceRegex = /[^\.]*(?=\..*)\.|.*/ + , nameRegex = /\..*/ + , addEvent = 'addEventListener' + , attachEvent = 'attachEvent' + , removeEvent = 'removeEventListener' + , detachEvent = 'detachEvent' + , doc = document || {} + , root = doc.documentElement || {} + , W3C_MODEL = root[addEvent] + , eventSupport = W3C_MODEL ? addEvent : attachEvent + , slice = Array.prototype.slice + , mouseTypeRegex = /click|mouse(?!(.*wheel|scroll))|menu|drag|drop/i + , mouseWheelTypeRegex = /mouse.*(wheel|scroll)/i + , textTypeRegex = /^text/i + , touchTypeRegex = /^touch|^gesture/i + , ONE = { one: 1 } // singleton for quick matching making add() do one() + + , nativeEvents = (function (hash, events, i) { + for (i = 0; i < events.length; i++) + hash[events[i]] = 1 + return hash + })({}, ( + 'click dblclick mouseup mousedown contextmenu ' + // mouse buttons + 'mousewheel mousemultiwheel DOMMouseScroll ' + // mouse wheel + 'mouseover mouseout mousemove selectstart selectend ' + // mouse movement + 'keydown keypress keyup ' + // keyboard + 'orientationchange ' + // mobile + 'focus blur change reset select submit ' + // form elements + 'load unload beforeunload resize move DOMContentLoaded readystatechange ' + // window + 'error abort scroll ' + // misc + (W3C_MODEL ? // element.fireEvent('onXYZ'... is not forgiving if we try to fire an event + // that doesn't actually exist, so make sure we only do these on newer browsers + 'show ' + // mouse buttons + 'input invalid ' + // form elements + 'touchstart touchmove touchend touchcancel ' + // touch + 'gesturestart gesturechange gestureend ' + // gesture + 'message readystatechange pageshow pagehide popstate ' + // window + 'hashchange offline online ' + // window + 'afterprint beforeprint ' + // printing + 'dragstart dragenter dragover dragleave drag drop dragend ' + // dnd + 'loadstart progress suspend emptied stalled loadmetadata ' + // media + 'loadeddata canplay canplaythrough playing waiting seeking ' + // media + 'seeked ended durationchange timeupdate play pause ratechange ' + // media + 'volumechange cuechange ' + // media + 'checking noupdate downloading cached updateready obsolete ' + // appcache + '' : '') + ).split(' ') + ) + + , customEvents = (function () { + function isDescendant(parent, node) { + while ((node = node.parentNode) !== null) { + if (node === parent) return true + } + return false + } + + function check(event) { + var related = event.relatedTarget + if (!related) return related === null + return (related !== this && related.prefix !== 'xul' && !/document/.test(this.toString()) && !isDescendant(this, related)) + } + + return { + mouseenter: { base: 'mouseover', condition: check } + , mouseleave: { base: 'mouseout', condition: check } + , mousewheel: { base: /Firefox/.test(navigator.userAgent) ? 'DOMMouseScroll' : 'mousewheel' } + } + })() + + , fixEvent = (function () { + var commonProps = 'altKey attrChange attrName bubbles cancelable ctrlKey currentTarget detail eventPhase getModifierState isTrusted metaKey relatedNode relatedTarget shiftKey srcElement target timeStamp type view which'.split(' ') + , mouseProps = commonProps.concat('button buttons clientX clientY dataTransfer fromElement offsetX offsetY pageX pageY screenX screenY toElement'.split(' ')) + , mouseWheelProps = mouseProps.concat('wheelDelta wheelDeltaX wheelDeltaY wheelDeltaZ axis'.split(' ')) // 'axis' is FF specific + , keyProps = commonProps.concat('char charCode key keyCode keyIdentifier keyLocation'.split(' ')) + , textProps = commonProps.concat(['data']) + , touchProps = commonProps.concat('touches targetTouches changedTouches scale rotation'.split(' ')) + , preventDefault = 'preventDefault' + , createPreventDefault = function (event) { + return function () { + if (event[preventDefault]) + event[preventDefault]() + else + event.returnValue = false + } + } + , stopPropagation = 'stopPropagation' + , createStopPropagation = function (event) { + return function () { + if (event[stopPropagation]) + event[stopPropagation]() + else + event.cancelBubble = true + } + } + , createStop = function (synEvent) { + return function () { + synEvent[preventDefault]() + synEvent[stopPropagation]() + synEvent.stopped = true + } + } + , copyProps = function (event, result, props) { + var i, p + for (i = props.length; i--;) { + p = props[i] + if (!(p in result) && p in event) result[p] = event[p] + } + } + + return function (event, isNative) { + var result = { originalEvent: event, isNative: isNative } + if (!event) + return result + + var props + , type = event.type + , target = event.target || event.srcElement + + result[preventDefault] = createPreventDefault(event) + result[stopPropagation] = createStopPropagation(event) + result.stop = createStop(result) + result.target = target && target.nodeType === 3 ? target.parentNode : target + + if (isNative) { // we only need basic augmentation on custom events, the rest is too expensive + if (type.indexOf('key') !== -1) { + props = keyProps + result.keyCode = event.which || event.keyCode + } else if (mouseTypeRegex.test(type)) { + props = mouseProps + result.rightClick = event.which === 3 || event.button === 2 + result.pos = { x: 0, y: 0 } + if (event.pageX || event.pageY) { + result.clientX = event.pageX + result.clientY = event.pageY + } else if (event.clientX || event.clientY) { + result.clientX = event.clientX + doc.body.scrollLeft + root.scrollLeft + result.clientY = event.clientY + doc.body.scrollTop + root.scrollTop + } + if (overOut.test(type)) + result.relatedTarget = event.relatedTarget || event[(type === 'mouseover' ? 'from' : 'to') + 'Element'] + } else if (touchTypeRegex.test(type)) { + props = touchProps + } else if (mouseWheelTypeRegex.test(type)) { + props = mouseWheelProps + } else if (textTypeRegex.test(type)) { + props = textProps + } + copyProps(event, result, props || commonProps) + } + return result + } + })() + + // if we're in old IE we can't do onpropertychange on doc or win so we use doc.documentElement for both + , targetElement = function (element, isNative) { + return !W3C_MODEL && !isNative && (element === doc || element === win) ? root : element + } + + // we use one of these per listener, of any type + , RegEntry = (function () { + function entry(element, type, handler, original, namespaces) { + this.element = element + this.type = type + this.handler = handler + this.original = original + this.namespaces = namespaces + this.custom = customEvents[type] + this.isNative = nativeEvents[type] && element[eventSupport] + this.eventType = W3C_MODEL || this.isNative ? type : 'propertychange' + this.customType = !W3C_MODEL && !this.isNative && type + this.target = targetElement(element, this.isNative) + this.eventSupport = this.target[eventSupport] + } + + entry.prototype = { + // given a list of namespaces, is our entry in any of them? + inNamespaces: function (checkNamespaces) { + var i, j + if (!checkNamespaces) + return true + if (!this.namespaces) + return false + for (i = checkNamespaces.length; i--;) { + for (j = this.namespaces.length; j--;) { + if (checkNamespaces[i] === this.namespaces[j]) + return true + } + } + return false + } + + // match by element, original fn (opt), handler fn (opt) + , matches: function (checkElement, checkOriginal, checkHandler) { + return this.element === checkElement && + (!checkOriginal || this.original === checkOriginal) && + (!checkHandler || this.handler === checkHandler) + } + } + + return entry + })() + + , registry = (function () { + // our map stores arrays by event type, just because it's better than storing + // everything in a single array. uses '$' as a prefix for the keys for safety + var map = {} + + // generic functional search of our registry for matching listeners, + // `fn` returns false to break out of the loop + , forAll = function (element, type, original, handler, fn) { + if (!type || type === '*') { + // search the whole registry + for (var t in map) { + if (t.charAt(0) === '$') + forAll(element, t.substr(1), original, handler, fn) + } + } else { + var i = 0, l, list = map['$' + type], all = element === '*' + if (!list) + return + for (l = list.length; i < l; i++) { + if (all || list[i].matches(element, original, handler)) + if (!fn(list[i], list, i, type)) + return + } + } + } + + , has = function (element, type, original) { + // we're not using forAll here simply because it's a bit slower and this + // needs to be fast + var i, list = map['$' + type] + if (list) { + for (i = list.length; i--;) { + if (list[i].matches(element, original, null)) + return true + } + } + return false + } + + , get = function (element, type, original) { + var entries = [] + forAll(element, type, original, null, function (entry) { return entries.push(entry) }) + return entries + } + + , put = function (entry) { + (map['$' + entry.type] || (map['$' + entry.type] = [])).push(entry) + return entry + } + + , del = function (entry) { + forAll(entry.element, entry.type, null, entry.handler, function (entry, list, i) { + list.splice(i, 1) + if (list.length === 0) + delete map['$' + entry.type] + return false + }) + } + + // dump all entries, used for onunload + , entries = function () { + var t, entries = [] + for (t in map) { + if (t.charAt(0) === '$') + entries = entries.concat(map[t]) + } + return entries + } + + return { has: has, get: get, put: put, del: del, entries: entries } + })() + + // add and remove listeners to DOM elements + , listener = W3C_MODEL ? function (element, type, fn, add) { + element[add ? addEvent : removeEvent](type, fn, false) + } : function (element, type, fn, add, custom) { + if (custom && add && element['_on' + custom] === null) + element['_on' + custom] = 0 + element[add ? attachEvent : detachEvent]('on' + type, fn) + } + + , nativeHandler = function (element, fn, args) { + return function (event) { + event = fixEvent(event || ((this.ownerDocument || this.document || this).parentWindow || win).event, true) + return fn.apply(element, [event].concat(args)) + } + } + + , customHandler = function (element, fn, type, condition, args, isNative) { + return function (event) { + if (condition ? condition.apply(this, arguments) : W3C_MODEL ? true : event && event.propertyName === '_on' + type || !event) { + if (event) + event = fixEvent(event || ((this.ownerDocument || this.document || this).parentWindow || win).event, isNative) + fn.apply(element, event && (!args || args.length === 0) ? arguments : slice.call(arguments, event ? 0 : 1).concat(args)) + } + } + } + + , once = function (rm, element, type, fn, originalFn) { + // wrap the handler in a handler that does a remove as well + return function () { + rm(element, type, originalFn) + fn.apply(this, arguments) + } + } + + , removeListener = function (element, orgType, handler, namespaces) { + var i, l, entry + , type = (orgType && orgType.replace(nameRegex, '')) + , handlers = registry.get(element, type, handler) + + for (i = 0, l = handlers.length; i < l; i++) { + if (handlers[i].inNamespaces(namespaces)) { + if ((entry = handlers[i]).eventSupport) + listener(entry.target, entry.eventType, entry.handler, false, entry.type) + // TODO: this is problematic, we have a registry.get() and registry.del() that + // both do registry searches so we waste cycles doing this. Needs to be rolled into + // a single registry.forAll(fn) that removes while finding, but the catch is that + // we'll be splicing the arrays that we're iterating over. Needs extra tests to + // make sure we don't screw it up. @rvagg + registry.del(entry) + } + } + } + + , addListener = function (element, orgType, fn, originalFn, args) { + var entry + , type = orgType.replace(nameRegex, '') + , namespaces = orgType.replace(namespaceRegex, '').split('.') + + if (registry.has(element, type, fn)) + return element // no dupe + if (type === 'unload') + fn = once(removeListener, element, type, fn, originalFn) // self clean-up + if (customEvents[type]) { + if (customEvents[type].condition) + fn = customHandler(element, fn, type, customEvents[type].condition, true) + type = customEvents[type].base || type + } + entry = registry.put(new RegEntry(element, type, fn, originalFn, namespaces[0] && namespaces)) + entry.handler = entry.isNative ? + nativeHandler(element, entry.handler, args) : + customHandler(element, entry.handler, type, false, args, false) + if (entry.eventSupport) + listener(entry.target, entry.eventType, entry.handler, true, entry.customType) + } + + , del = function (selector, fn, $) { + return function (e) { + var target, i, array = typeof selector === 'string' ? $(selector, this) : selector + for (target = e.target; target && target !== this; target = target.parentNode) { + for (i = array.length; i--;) { + if (array[i] === target) { + return fn.apply(target, arguments) + } + } + } + } + } + + , remove = function (element, typeSpec, fn) { + var k, m, type, namespaces, i + , rm = removeListener + , isString = typeSpec && typeof typeSpec === 'string' + + if (isString && typeSpec.indexOf(' ') > 0) { + // remove(el, 't1 t2 t3', fn) or remove(el, 't1 t2 t3') + typeSpec = typeSpec.split(' ') + for (i = typeSpec.length; i--;) + remove(element, typeSpec[i], fn) + return element + } + type = isString && typeSpec.replace(nameRegex, '') + if (type && customEvents[type]) + type = customEvents[type].type + if (!typeSpec || isString) { + // remove(el) or remove(el, t1.ns) or remove(el, .ns) or remove(el, .ns1.ns2.ns3) + if (namespaces = isString && typeSpec.replace(namespaceRegex, '')) + namespaces = namespaces.split('.') + rm(element, type, fn, namespaces) + } else if (typeof typeSpec === 'function') { + // remove(el, fn) + rm(element, null, typeSpec) + } else { + // remove(el, { t1: fn1, t2, fn2 }) + for (k in typeSpec) { + if (typeSpec.hasOwnProperty(k)) + remove(element, k, typeSpec[k]) + } + } + return element + } + + , add = function (element, events, fn, delfn, $) { + var type, types, i, args + , originalFn = fn + , isDel = fn && typeof fn === 'string' + + if (events && !fn && typeof events === 'object') { + for (type in events) { + if (events.hasOwnProperty(type)) + add.apply(this, [ element, type, events[type] ]) + } + } else { + args = arguments.length > 3 ? slice.call(arguments, 3) : [] + types = (isDel ? fn : events).split(' ') + isDel && (fn = del(events, (originalFn = delfn), $)) && (args = slice.call(args, 1)) + // special case for one() + this === ONE && (fn = once(remove, element, events, fn, originalFn)) + for (i = types.length; i--;) addListener(element, types[i], fn, originalFn, args) + } + return element + } + + , one = function () { + return add.apply(ONE, arguments) + } + + , fireListener = W3C_MODEL ? function (isNative, type, element) { + var evt = doc.createEvent(isNative ? 'HTMLEvents' : 'UIEvents') + evt[isNative ? 'initEvent' : 'initUIEvent'](type, true, true, win, 1) + element.dispatchEvent(evt) + } : function (isNative, type, element) { + element = targetElement(element, isNative) + // if not-native then we're using onpropertychange so we just increment a custom property + isNative ? element.fireEvent('on' + type, doc.createEventObject()) : element['_on' + type]++ + } + + , fire = function (element, type, args) { + var i, j, l, names, handlers + , types = type.split(' ') + + for (i = types.length; i--;) { + type = types[i].replace(nameRegex, '') + if (names = types[i].replace(namespaceRegex, '')) + names = names.split('.') + if (!names && !args && element[eventSupport]) { + fireListener(nativeEvents[type], type, element) + } else { + // non-native event, either because of a namespace, arguments or a non DOM element + // iterate over all listeners and manually 'fire' + handlers = registry.get(element, type) + args = [false].concat(args) + for (j = 0, l = handlers.length; j < l; j++) { + if (handlers[j].inNamespaces(names)) + handlers[j].handler.apply(element, args) + } + } + } + return element + } + + , clone = function (element, from, type) { + var i = 0 + , handlers = registry.get(from, type) + , l = handlers.length + + for (;i < l; i++) + handlers[i].original && add(element, handlers[i].type, handlers[i].original) + return element + } + + , bean = { + add: add + , one: one + , remove: remove + , clone: clone + , fire: fire + , noConflict: function () { + context[name] = old + return this + } + } + + if (win[attachEvent]) { + // for IE, clean up on unload to avoid leaks + var cleanup = function () { + var i, entries = registry.entries() + for (i in entries) { + if (entries[i].type && entries[i].type !== 'unload') + remove(entries[i].element, entries[i].type) + } + win[detachEvent]('onunload', cleanup) + win.CollectGarbage && win.CollectGarbage() + } + win[attachEvent]('onunload', cleanup) + } + + return bean +}) +// Copyright Google Inc. +// Licensed under the Apache Licence Version 2.0 +// Autogenerated at Tue Oct 11 13:36:46 EDT 2011 +// @provides html4 +var html4 = {}; +html4.atype = { + NONE: 0, + URI: 1, + URI_FRAGMENT: 11, + SCRIPT: 2, + STYLE: 3, + ID: 4, + IDREF: 5, + IDREFS: 6, + GLOBAL_NAME: 7, + LOCAL_NAME: 8, + CLASSES: 9, + FRAME_TARGET: 10 +}; +html4.ATTRIBS = { + '*::class': 9, + '*::dir': 0, + '*::id': 4, + '*::lang': 0, + '*::onclick': 2, + '*::ondblclick': 2, + '*::onkeydown': 2, + '*::onkeypress': 2, + '*::onkeyup': 2, + '*::onload': 2, + '*::onmousedown': 2, + '*::onmousemove': 2, + '*::onmouseout': 2, + '*::onmouseover': 2, + '*::onmouseup': 2, + '*::style': 3, + '*::title': 0, + 'a::accesskey': 0, + 'a::coords': 0, + 'a::href': 1, + 'a::hreflang': 0, + 'a::name': 7, + 'a::onblur': 2, + 'a::onfocus': 2, + 'a::rel': 0, + 'a::rev': 0, + 'a::shape': 0, + 'a::tabindex': 0, + 'a::target': 10, + 'a::type': 0, + 'area::accesskey': 0, + 'area::alt': 0, + 'area::coords': 0, + 'area::href': 1, + 'area::nohref': 0, + 'area::onblur': 2, + 'area::onfocus': 2, + 'area::shape': 0, + 'area::tabindex': 0, + 'area::target': 10, + 'bdo::dir': 0, + 'blockquote::cite': 1, + 'br::clear': 0, + 'button::accesskey': 0, + 'button::disabled': 0, + 'button::name': 8, + 'button::onblur': 2, + 'button::onfocus': 2, + 'button::tabindex': 0, + 'button::type': 0, + 'button::value': 0, + 'canvas::height': 0, + 'canvas::width': 0, + 'caption::align': 0, + 'col::align': 0, + 'col::char': 0, + 'col::charoff': 0, + 'col::span': 0, + 'col::valign': 0, + 'col::width': 0, + 'colgroup::align': 0, + 'colgroup::char': 0, + 'colgroup::charoff': 0, + 'colgroup::span': 0, + 'colgroup::valign': 0, + 'colgroup::width': 0, + 'del::cite': 1, + 'del::datetime': 0, + 'dir::compact': 0, + 'div::align': 0, + 'dl::compact': 0, + 'font::color': 0, + 'font::face': 0, + 'font::size': 0, + 'form::accept': 0, + 'form::action': 1, + 'form::autocomplete': 0, + 'form::enctype': 0, + 'form::method': 0, + 'form::name': 7, + 'form::onreset': 2, + 'form::onsubmit': 2, + 'form::target': 10, + 'h1::align': 0, + 'h2::align': 0, + 'h3::align': 0, + 'h4::align': 0, + 'h5::align': 0, + 'h6::align': 0, + 'hr::align': 0, + 'hr::noshade': 0, + 'hr::size': 0, + 'hr::width': 0, + 'iframe::align': 0, + 'iframe::frameborder': 0, + 'iframe::height': 0, + 'iframe::marginheight': 0, + 'iframe::marginwidth': 0, + 'iframe::width': 0, + 'img::align': 0, + 'img::alt': 0, + 'img::border': 0, + 'img::height': 0, + 'img::hspace': 0, + 'img::ismap': 0, + 'img::name': 7, + 'img::src': 1, + 'img::usemap': 11, + 'img::vspace': 0, + 'img::width': 0, + 'input::accept': 0, + 'input::accesskey': 0, + 'input::align': 0, + 'input::alt': 0, + 'input::autocomplete': 0, + 'input::checked': 0, + 'input::disabled': 0, + 'input::ismap': 0, + 'input::maxlength': 0, + 'input::name': 8, + 'input::onblur': 2, + 'input::onchange': 2, + 'input::onfocus': 2, + 'input::onselect': 2, + 'input::readonly': 0, + 'input::size': 0, + 'input::src': 1, + 'input::tabindex': 0, + 'input::type': 0, + 'input::usemap': 11, + 'input::value': 0, + 'ins::cite': 1, + 'ins::datetime': 0, + 'label::accesskey': 0, + 'label::for': 5, + 'label::onblur': 2, + 'label::onfocus': 2, + 'legend::accesskey': 0, + 'legend::align': 0, + 'li::type': 0, + 'li::value': 0, + 'map::name': 7, + 'menu::compact': 0, + 'ol::compact': 0, + 'ol::start': 0, + 'ol::type': 0, + 'optgroup::disabled': 0, + 'optgroup::label': 0, + 'option::disabled': 0, + 'option::label': 0, + 'option::selected': 0, + 'option::value': 0, + 'p::align': 0, + 'pre::width': 0, + 'q::cite': 1, + 'select::disabled': 0, + 'select::multiple': 0, + 'select::name': 8, + 'select::onblur': 2, + 'select::onchange': 2, + 'select::onfocus': 2, + 'select::size': 0, + 'select::tabindex': 0, + 'table::align': 0, + 'table::bgcolor': 0, + 'table::border': 0, + 'table::cellpadding': 0, + 'table::cellspacing': 0, + 'table::frame': 0, + 'table::rules': 0, + 'table::summary': 0, + 'table::width': 0, + 'tbody::align': 0, + 'tbody::char': 0, + 'tbody::charoff': 0, + 'tbody::valign': 0, + 'td::abbr': 0, + 'td::align': 0, + 'td::axis': 0, + 'td::bgcolor': 0, + 'td::char': 0, + 'td::charoff': 0, + 'td::colspan': 0, + 'td::headers': 6, + 'td::height': 0, + 'td::nowrap': 0, + 'td::rowspan': 0, + 'td::scope': 0, + 'td::valign': 0, + 'td::width': 0, + 'textarea::accesskey': 0, + 'textarea::cols': 0, + 'textarea::disabled': 0, + 'textarea::name': 8, + 'textarea::onblur': 2, + 'textarea::onchange': 2, + 'textarea::onfocus': 2, + 'textarea::onselect': 2, + 'textarea::readonly': 0, + 'textarea::rows': 0, + 'textarea::tabindex': 0, + 'tfoot::align': 0, + 'tfoot::char': 0, + 'tfoot::charoff': 0, + 'tfoot::valign': 0, + 'th::abbr': 0, + 'th::align': 0, + 'th::axis': 0, + 'th::bgcolor': 0, + 'th::char': 0, + 'th::charoff': 0, + 'th::colspan': 0, + 'th::headers': 6, + 'th::height': 0, + 'th::nowrap': 0, + 'th::rowspan': 0, + 'th::scope': 0, + 'th::valign': 0, + 'th::width': 0, + 'thead::align': 0, + 'thead::char': 0, + 'thead::charoff': 0, + 'thead::valign': 0, + 'tr::align': 0, + 'tr::bgcolor': 0, + 'tr::char': 0, + 'tr::charoff': 0, + 'tr::valign': 0, + 'ul::compact': 0, + 'ul::type': 0 +}; +html4.eflags = { + OPTIONAL_ENDTAG: 1, + EMPTY: 2, + CDATA: 4, + RCDATA: 8, + UNSAFE: 16, + FOLDABLE: 32, + SCRIPT: 64, + STYLE: 128 +}; +html4.ELEMENTS = { + 'a': 0, + 'abbr': 0, + 'acronym': 0, + 'address': 0, + 'applet': 16, + 'area': 2, + 'b': 0, + 'base': 18, + 'basefont': 18, + 'bdo': 0, + 'big': 0, + 'blockquote': 0, + 'body': 49, + 'br': 2, + 'button': 0, + 'canvas': 0, + 'caption': 0, + 'center': 0, + 'cite': 0, + 'code': 0, + 'col': 2, + 'colgroup': 1, + 'dd': 1, + 'del': 0, + 'dfn': 0, + 'dir': 0, + 'div': 0, + 'dl': 0, + 'dt': 1, + 'em': 0, + 'fieldset': 0, + 'font': 0, + 'form': 0, + 'frame': 18, + 'frameset': 16, + 'h1': 0, + 'h2': 0, + 'h3': 0, + 'h4': 0, + 'h5': 0, + 'h6': 0, + 'head': 49, + 'hr': 2, + 'html': 49, + 'i': 0, + 'iframe': 4, + 'img': 2, + 'input': 2, + 'ins': 0, + 'isindex': 18, + 'kbd': 0, + 'label': 0, + 'legend': 0, + 'li': 1, + 'link': 18, + 'map': 0, + 'menu': 0, + 'meta': 18, + 'nobr': 0, + 'noembed': 4, + 'noframes': 20, + 'noscript': 20, + 'object': 16, + 'ol': 0, + 'optgroup': 0, + 'option': 1, + 'p': 1, + 'param': 18, + 'pre': 0, + 'q': 0, + 's': 0, + 'samp': 0, + 'script': 84, + 'select': 0, + 'small': 0, + 'span': 0, + 'strike': 0, + 'strong': 0, + 'style': 148, + 'sub': 0, + 'sup': 0, + 'table': 0, + 'tbody': 1, + 'td': 1, + 'textarea': 8, + 'tfoot': 1, + 'th': 1, + 'thead': 1, + 'title': 24, + 'tr': 1, + 'tt': 0, + 'u': 0, + 'ul': 0, + 'var': 0 +}; +html4.ueffects = { + NOT_LOADED: 0, + SAME_DOCUMENT: 1, + NEW_DOCUMENT: 2 +}; +html4.URIEFFECTS = { + 'a::href': 2, + 'area::href': 2, + 'blockquote::cite': 0, + 'body::background': 1, + 'del::cite': 0, + 'form::action': 2, + 'img::src': 1, + 'input::src': 1, + 'ins::cite': 0, + 'q::cite': 0 +}; +html4.ltypes = { + UNSANDBOXED: 2, + SANDBOXED: 1, + DATA: 0 +}; +html4.LOADERTYPES = { + 'a::href': 2, + 'area::href': 2, + 'blockquote::cite': 2, + 'body::background': 1, + 'del::cite': 2, + 'form::action': 2, + 'img::src': 1, + 'input::src': 1, + 'ins::cite': 2, + 'q::cite': 2 +};; +// Copyright (C) 2006 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @fileoverview + * An HTML sanitizer that can satisfy a variety of security policies. + * + *

+ * The HTML sanitizer is built around a SAX parser and HTML element and + * attributes schemas. + * + * @author mikesamuel@gmail.com + * @requires html4 + * @overrides window + * @provides html, html_sanitize + */ + +/** + * @namespace + */ +var html = (function (html4) { + var lcase; + // The below may not be true on browsers in the Turkish locale. + if ('script' === 'SCRIPT'.toLowerCase()) { + lcase = function (s) { return s.toLowerCase(); }; + } else { + /** + * {@updoc + * $ lcase('SCRIPT') + * # 'script' + * $ lcase('script') + * # 'script' + * } + */ + lcase = function (s) { + return s.replace( + /[A-Z]/g, + function (ch) { + return String.fromCharCode(ch.charCodeAt(0) | 32); + }); + }; + } + + var ENTITIES = { + lt : '<', + gt : '>', + amp : '&', + nbsp : '\240', + quot : '"', + apos : '\'' + }; + + // Schemes on which to defer to uripolicy. Urls with other schemes are denied + var WHITELISTED_SCHEMES = /^(?:https?|mailto|data)$/i; + + var decimalEscapeRe = /^#(\d+)$/; + var hexEscapeRe = /^#x([0-9A-Fa-f]+)$/; + /** + * Decodes an HTML entity. + * + * {@updoc + * $ lookupEntity('lt') + * # '<' + * $ lookupEntity('GT') + * # '>' + * $ lookupEntity('amp') + * # '&' + * $ lookupEntity('nbsp') + * # '\xA0' + * $ lookupEntity('apos') + * # "'" + * $ lookupEntity('quot') + * # '"' + * $ lookupEntity('#xa') + * # '\n' + * $ lookupEntity('#10') + * # '\n' + * $ lookupEntity('#x0a') + * # '\n' + * $ lookupEntity('#010') + * # '\n' + * $ lookupEntity('#x00A') + * # '\n' + * $ lookupEntity('Pi') // Known failure + * # '\u03A0' + * $ lookupEntity('pi') // Known failure + * # '\u03C0' + * } + * + * @param name the content between the '&' and the ';'. + * @return a single unicode code-point as a string. + */ + function lookupEntity(name) { + name = lcase(name); // TODO: π is different from Π + if (ENTITIES.hasOwnProperty(name)) { return ENTITIES[name]; } + var m = name.match(decimalEscapeRe); + if (m) { + return String.fromCharCode(parseInt(m[1], 10)); + } else if (!!(m = name.match(hexEscapeRe))) { + return String.fromCharCode(parseInt(m[1], 16)); + } + return ''; + } + + function decodeOneEntity(_, name) { + return lookupEntity(name); + } + + var nulRe = /\0/g; + function stripNULs(s) { + return s.replace(nulRe, ''); + } + + var entityRe = /&(#\d+|#x[0-9A-Fa-f]+|\w+);/g; + /** + * The plain text of a chunk of HTML CDATA which possibly containing. + * + * {@updoc + * $ unescapeEntities('') + * # '' + * $ unescapeEntities('hello World!') + * # 'hello World!' + * $ unescapeEntities('1 < 2 && 4 > 3 ') + * # '1 < 2 && 4 > 3\n' + * $ unescapeEntities('<< <- unfinished entity>') + * # '<< <- unfinished entity>' + * $ unescapeEntities('/foo?bar=baz©=true') // & often unescaped in URLS + * # '/foo?bar=baz©=true' + * $ unescapeEntities('pi=ππ, Pi=Π\u03A0') // FIXME: known failure + * # 'pi=\u03C0\u03c0, Pi=\u03A0\u03A0' + * } + * + * @param s a chunk of HTML CDATA. It must not start or end inside an HTML + * entity. + */ + function unescapeEntities(s) { + return s.replace(entityRe, decodeOneEntity); + } + + var ampRe = /&/g; + var looseAmpRe = /&([^a-z#]|#(?:[^0-9x]|x(?:[^0-9a-f]|$)|$)|$)/gi; + var ltRe = //g; + var quotRe = /\"/g; + var eqRe = /\=/g; // Backslash required on JScript.net + + /** + * Escapes HTML special characters in attribute values as HTML entities. + * + * {@updoc + * $ escapeAttrib('') + * # '' + * $ escapeAttrib('"<<&==&>>"') // Do not just escape the first occurrence. + * # '"<<&==&>>"' + * $ escapeAttrib('Hello !') + * # 'Hello <World>!' + * } + */ + function escapeAttrib(s) { + // Escaping '=' defangs many UTF-7 and SGML short-tag attacks. + return s.replace(ampRe, '&').replace(ltRe, '<').replace(gtRe, '>') + .replace(quotRe, '"').replace(eqRe, '='); + } + + /** + * Escape entities in RCDATA that can be escaped without changing the meaning. + * {@updoc + * $ normalizeRCData('1 < 2 && 3 > 4 && 5 < 7&8') + * # '1 < 2 && 3 > 4 && 5 < 7&8' + * } + */ + function normalizeRCData(rcdata) { + return rcdata + .replace(looseAmpRe, '&$1') + .replace(ltRe, '<') + .replace(gtRe, '>'); + } + + + // TODO(mikesamuel): validate sanitizer regexs against the HTML5 grammar at + // http://www.whatwg.org/specs/web-apps/current-work/multipage/syntax.html + // http://www.whatwg.org/specs/web-apps/current-work/multipage/parsing.html + // http://www.whatwg.org/specs/web-apps/current-work/multipage/tokenization.html + // http://www.whatwg.org/specs/web-apps/current-work/multipage/tree-construction.html + + /** token definitions. */ + var INSIDE_TAG_TOKEN = new RegExp( + // Don't capture space. + '^\\s*(?:' + // Capture an attribute name in group 1, and value in group 3. + // We capture the fact that there was an attribute in group 2, since + // interpreters are inconsistent in whether a group that matches nothing + // is null, undefined, or the empty string. + + ('(?:' + + '([a-z][a-z-]*)' // attribute name + + ('(' // optionally followed + + '\\s*=\\s*' + + ('(' + // A double quoted string. + + '\"[^\"]*\"' + // A single quoted string. + + '|\'[^\']*\'' + // The positive lookahead is used to make sure that in + // , the value for bar is blank, not "baz=boo". + + '|(?=[a-z][a-z-]*\\s*=)' + // An unquoted value that is not an attribute name. + // We know it is not an attribute name because the previous + // zero-width match would've eliminated that possibility. + + '|[^>\"\'\\s]*' + + ')' + ) + + ')' + ) + '?' + + ')' + ) + // End of tag captured in group 3. + + '|(\/?>)' + // Don't capture cruft + + '|[\\s\\S][^a-z\\s>]*)', + 'i'); + + var OUTSIDE_TAG_TOKEN = new RegExp( + '^(?:' + // Entity captured in group 1. + + '&(\\#[0-9]+|\\#[x][0-9a-f]+|\\w+);' + // Comment, doctypes, and processing instructions not captured. + + '|<\!--[\\s\\S]*?--\>|]*>|<\\?[^>*]*>' + // '/' captured in group 2 for close tags, and name captured in group 3. + + '|<(\/)?([a-z][a-z0-9]*)' + // Text captured in group 4. + + '|([^<&>]+)' + // Cruft captured in group 5. + + '|([<&>]))', + 'i'); + + /** + * Given a SAX-like event handler, produce a function that feeds those + * events and a parameter to the event handler. + * + * The event handler has the form:{@code + * { + * // Name is an upper-case HTML tag name. Attribs is an array of + * // alternating upper-case attribute names, and attribute values. The + * // attribs array is reused by the parser. Param is the value passed to + * // the saxParser. + * startTag: function (name, attribs, param) { ... }, + * endTag: function (name, param) { ... }, + * pcdata: function (text, param) { ... }, + * rcdata: function (text, param) { ... }, + * cdata: function (text, param) { ... }, + * startDoc: function (param) { ... }, + * endDoc: function (param) { ... } + * }} + * + * @param {Object} handler a record containing event handlers. + * @return {Function} that takes a chunk of html and a parameter. + * The parameter is passed on to the handler methods. + */ + function makeSaxParser(handler) { + return function parse(htmlText, param) { + htmlText = String(htmlText); + var htmlLower = null; + + var inTag = false; // True iff we're currently processing a tag. + var attribs = []; // Accumulates attribute names and values. + var tagName = void 0; // The name of the tag currently being processed. + var eflags = void 0; // The element flags for the current tag. + var openTag = void 0; // True if the current tag is an open tag. + + if (handler.startDoc) { handler.startDoc(param); } + + while (htmlText) { + var m = htmlText.match(inTag ? INSIDE_TAG_TOKEN : OUTSIDE_TAG_TOKEN); + htmlText = htmlText.substring(m[0].length); + + if (inTag) { + if (m[1]) { // attribute + // setAttribute with uppercase names doesn't work on IE6. + var attribName = lcase(m[1]); + var decodedValue; + if (m[2]) { + var encodedValue = m[3]; + switch (encodedValue.charCodeAt(0)) { // Strip quotes + case 34: case 39: + encodedValue = encodedValue.substring( + 1, encodedValue.length - 1); + break; + } + decodedValue = unescapeEntities(stripNULs(encodedValue)); + } else { + // Use name as value for valueless attribs, so + // + // gets attributes ['type', 'checkbox', 'checked', 'checked'] + decodedValue = attribName; + } + attribs.push(attribName, decodedValue); + } else if (m[4]) { + if (eflags !== void 0) { // False if not in whitelist. + if (openTag) { + if (handler.startTag) { + handler.startTag(tagName, attribs, param); + } + } else { + if (handler.endTag) { + handler.endTag(tagName, param); + } + } + } + + if (openTag + && (eflags & (html4.eflags.CDATA | html4.eflags.RCDATA))) { + if (htmlLower === null) { + htmlLower = lcase(htmlText); + } else { + htmlLower = htmlLower.substring( + htmlLower.length - htmlText.length); + } + var dataEnd = htmlLower.indexOf('' ? '>' : '&', + param); + } + } + } + } + + if (handler.endDoc) { handler.endDoc(param); } + }; + } + + /** + * Returns a function that strips unsafe tags and attributes from html. + * @param {Function} sanitizeAttributes + * maps from (tagName, attribs[]) to null or a sanitized attribute array. + * The attribs array can be arbitrarily modified, but the same array + * instance is reused, so should not be held. + * @return {Function} from html to sanitized html + */ + function makeHtmlSanitizer(sanitizeAttributes) { + var stack; + var ignoring; + return makeSaxParser({ + startDoc: function (_) { + stack = []; + ignoring = false; + }, + startTag: function (tagName, attribs, out) { + if (ignoring) { return; } + if (!html4.ELEMENTS.hasOwnProperty(tagName)) { return; } + var eflags = html4.ELEMENTS[tagName]; + if (eflags & html4.eflags.FOLDABLE) { + return; + } else if (eflags & html4.eflags.UNSAFE) { + ignoring = !(eflags & html4.eflags.EMPTY); + return; + } + attribs = sanitizeAttributes(tagName, attribs); + // TODO(mikesamuel): relying on sanitizeAttributes not to + // insert unsafe attribute names. + if (attribs) { + if (!(eflags & html4.eflags.EMPTY)) { + stack.push(tagName); + } + + out.push('<', tagName); + for (var i = 0, n = attribs.length; i < n; i += 2) { + var attribName = attribs[i], + value = attribs[i + 1]; + if (value !== null && value !== void 0) { + out.push(' ', attribName, '="', escapeAttrib(value), '"'); + } + } + out.push('>'); + } + }, + endTag: function (tagName, out) { + if (ignoring) { + ignoring = false; + return; + } + if (!html4.ELEMENTS.hasOwnProperty(tagName)) { return; } + var eflags = html4.ELEMENTS[tagName]; + if (!(eflags & (html4.eflags.UNSAFE | html4.eflags.EMPTY + | html4.eflags.FOLDABLE))) { + var index; + if (eflags & html4.eflags.OPTIONAL_ENDTAG) { + for (index = stack.length; --index >= 0;) { + var stackEl = stack[index]; + if (stackEl === tagName) { break; } + if (!(html4.ELEMENTS[stackEl] + & html4.eflags.OPTIONAL_ENDTAG)) { + // Don't pop non optional end tags looking for a match. + return; + } + } + } else { + for (index = stack.length; --index >= 0;) { + if (stack[index] === tagName) { break; } + } + } + if (index < 0) { return; } // Not opened. + for (var i = stack.length; --i > index;) { + var stackEl = stack[i]; + if (!(html4.ELEMENTS[stackEl] + & html4.eflags.OPTIONAL_ENDTAG)) { + out.push(''); + } + } + stack.length = index; + out.push(''); + } + }, + pcdata: function (text, out) { + if (!ignoring) { out.push(text); } + }, + rcdata: function (text, out) { + if (!ignoring) { out.push(text); } + }, + cdata: function (text, out) { + if (!ignoring) { out.push(text); } + }, + endDoc: function (out) { + for (var i = stack.length; --i >= 0;) { + out.push(''); + } + stack.length = 0; + } + }); + } + + // From RFC3986 + var URI_SCHEME_RE = new RegExp( + "^" + + "(?:" + + "([^:\/?#]+)" + // scheme + ":)?" + ); + + /** + * Strips unsafe tags and attributes from html. + * @param {string} htmlText to sanitize + * @param {Function} opt_uriPolicy -- a transform to apply to uri/url + * attribute values. If no opt_uriPolicy is provided, no uris + * are allowed ie. the default uriPolicy rewrites all uris to null + * @param {Function} opt_nmTokenPolicy : string -> string? -- a transform to + * apply to names, ids, and classes. If no opt_nmTokenPolicy is provided, + * all names, ids and classes are passed through ie. the default + * nmTokenPolicy is an identity transform + * @return {string} html + */ + function sanitize(htmlText, opt_uriPolicy, opt_nmTokenPolicy) { + var out = []; + makeHtmlSanitizer( + function sanitizeAttribs(tagName, attribs) { + for (var i = 0; i < attribs.length; i += 2) { + var attribName = attribs[i]; + var value = attribs[i + 1]; + var atype = null, attribKey; + if ((attribKey = tagName + '::' + attribName, + html4.ATTRIBS.hasOwnProperty(attribKey)) + || (attribKey = '*::' + attribName, + html4.ATTRIBS.hasOwnProperty(attribKey))) { + atype = html4.ATTRIBS[attribKey]; + } + if (atype !== null) { + switch (atype) { + case html4.atype.NONE: break; + case html4.atype.SCRIPT: + case html4.atype.STYLE: + value = null; + break; + case html4.atype.ID: + case html4.atype.IDREF: + case html4.atype.IDREFS: + case html4.atype.GLOBAL_NAME: + case html4.atype.LOCAL_NAME: + case html4.atype.CLASSES: + value = opt_nmTokenPolicy ? opt_nmTokenPolicy(value) : value; + break; + case html4.atype.URI: + var parsedUri = ('' + value).match(URI_SCHEME_RE); + if (!parsedUri) { + value = null; + } else if (!parsedUri[1] || + WHITELISTED_SCHEMES.test(parsedUri[1])) { + value = opt_uriPolicy && opt_uriPolicy(value); + } else { + value = null; + } + break; + case html4.atype.URI_FRAGMENT: + if (value && '#' === value.charAt(0)) { + value = opt_nmTokenPolicy ? opt_nmTokenPolicy(value) : value; + if (value) { value = '#' + value; } + } else { + value = null; + } + break; + default: + value = null; + break; + } + } else { + value = null; + } + attribs[i + 1] = value; + } + return attribs; + })(htmlText, out); + return out.join(''); + } + + return { + escapeAttrib: escapeAttrib, + makeHtmlSanitizer: makeHtmlSanitizer, + makeSaxParser: makeSaxParser, + normalizeRCData: normalizeRCData, + sanitize: sanitize, + unescapeEntities: unescapeEntities + }; +})(html4); + +var html_sanitize = html.sanitize; + +// Exports for closure compiler. Note this file is also cajoled +// for domado and run in an environment without 'window' +if (typeof window !== 'undefined') { + window['html'] = html; + window['html_sanitize'] = html_sanitize; +} +// Loosen restrictions of Caja's +// html-sanitizer to allow for styling +html4.ATTRIBS['*::style'] = 0; +html4.ELEMENTS['style'] = 0; + +html4.ATTRIBS['a::target'] = 0; + +html4.ELEMENTS['video'] = 0; +html4.ATTRIBS['video::src'] = 0; +html4.ATTRIBS['video::poster'] = 0; +html4.ATTRIBS['video::controls'] = 0; + +html4.ELEMENTS['audio'] = 0; +html4.ATTRIBS['audio::src'] = 0; +html4.ATTRIBS['video::autoplay'] = 0; +html4.ATTRIBS['video::controls'] = 0; +/*! + * mustache.js - Logic-less {{mustache}} templates with JavaScript + * http://github.com/janl/mustache.js + */ +var Mustache = (typeof module !== "undefined" && module.exports) || {}; + +(function (exports) { + + exports.name = "mustache.js"; + exports.version = "0.5.0-dev"; + exports.tags = ["{{", "}}"]; + exports.parse = parse; + exports.compile = compile; + exports.render = render; + exports.clearCache = clearCache; + + // This is here for backwards compatibility with 0.4.x. + exports.to_html = function (template, view, partials, send) { + var result = render(template, view, partials); + + if (typeof send === "function") { + send(result); + } else { + return result; + } + }; + + var _toString = Object.prototype.toString; + var _isArray = Array.isArray; + var _forEach = Array.prototype.forEach; + var _trim = String.prototype.trim; + + var isArray; + if (_isArray) { + isArray = _isArray; + } else { + isArray = function (obj) { + return _toString.call(obj) === "[object Array]"; + }; + } + + var forEach; + if (_forEach) { + forEach = function (obj, callback, scope) { + return _forEach.call(obj, callback, scope); + }; + } else { + forEach = function (obj, callback, scope) { + for (var i = 0, len = obj.length; i < len; ++i) { + callback.call(scope, obj[i], i, obj); + } + }; + } + + var spaceRe = /^\s*$/; + + function isWhitespace(string) { + return spaceRe.test(string); + } + + var trim; + if (_trim) { + trim = function (string) { + return string == null ? "" : _trim.call(string); + }; + } else { + var trimLeft, trimRight; + + if (isWhitespace("\xA0")) { + trimLeft = /^\s+/; + trimRight = /\s+$/; + } else { + // IE doesn't match non-breaking spaces with \s, thanks jQuery. + trimLeft = /^[\s\xA0]+/; + trimRight = /[\s\xA0]+$/; + } + + trim = function (string) { + return string == null ? "" : + String(string).replace(trimLeft, "").replace(trimRight, ""); + }; + } + + var escapeMap = { + "&": "&", + "<": "<", + ">": ">", + '"': '"', + "'": ''', + "/": '/' + }; + + function escapeHTML(string) { + return String(string).replace(/[&<>"'\/]/g, function (s) { + return escapeMap[s] || s; + }); + } + + /** + * Adds the `template`, `line`, and `file` properties to the given error + * object and alters the message to provide more useful debugging information. + */ + function debug(e, template, line, file) { + file = file || "