/*global define */ define(['jquery', 'underscore', 'backbone', 'd3'], function($, _, Backbone, d3) { 'use strict'; /** * @class MetricsChartView * @classdesc The MetricsChartView will render an SVG times-series chart using D3 that shows the number of metrics over time. * @screenshot views/MetricsChartView.png * @classcategory Views */ var MetricsChartView = Backbone.View.extend( /** @lends MetricsChartView.prototype */{ initialize: function (options) { if(!d3){ console.log('SVG is not supported'); return null; } if(typeof options !== "undefined"){ this.model = options.model || null; // TODO: figure out how to set the model on this view this.metricCount = options.metricCount || "0"; // for now, use individual arrays this.metricMonths = options.metricMonths || "0"; this.id = options.id || "metrics-chart"; this.viewType = options.type || "dataset"; this.width = options.width || 600; this.height = options.height || 370; this.metricName = options.metricName; } }, // http://stackoverflow.com/questions/9651167/svg-not-rendering-properly-as-a-backbone-view // Give our el a svg namespace because Backbone gives a different one automatically nameSpace: "http://www.w3.org/2000/svg", _ensureElement: function() { if (!this.el) { var attrs = _.extend({}, _.result(this, 'attributes')); if (this.id) attrs.id = _.result(this, 'id'); if (this.className) attrs['class'] = _.result(this, 'className'); var $el = $(window.document.createElementNS(_.result(this, 'nameSpace'), _.result(this, 'tagName'))).attr(attrs); this.setElement($el, false); } else { this.setElement(_.result(this, 'el'), false); } }, tagName: "svg", /** Renders this Metric Chart view. */ render: function(){ //Clear out any view elements in case this is a re-render this.$el.empty(); /* * ======================================================================== * NAMING CONVENTIONS: CONTEXT: Context refers to the mini slider chart at the bottom, that includes the d3 "brush" FOCUS: Focus refers to the larger main chart at the top BRUSH: The rectangle in the context chart that highlights what is currently in focus in the focus chart. * ======================================================================== */ // check if there have been any views/citations var sumMetricCount = 0; for (var i = 0; i < this.metricCount.length; i++) { sumMetricCount += this.metricCount[i] } var self = this; // when ther no data or no views/citations yet, just show some text: if(this.metricCount.length == 0 || this.metricCount == 0 || sumMetricCount ==0){ var metricNameLemma = this.metricName.toLowerCase().substring(0, this.metricName.length - 1); var textMessage = "This dataset hasn’t been " + metricNameLemma + "ed yet." if (this.viewType != "dataset") { textMessage = "These datasets have not been " + metricNameLemma + "ed yet." } var margin = {top: 25, right: 40, bottom: 40, left: 40}, height = this.height - margin.top - margin.bottom; //Set the chart width this.$el.css("width", "100%"); var width = (this.$el.width() || this.width) - margin.left - margin.right; this.width = width; var vis = d3.select(this.el) .attr("width", width + margin.left + margin.right) .attr("height", height + margin.top + margin.bottom) .attr("class", "line-chart no-data"); var bkg = vis.append("svg:rect") .attr("class", "no-data") .attr("width", width) .attr("height", height) .attr("rx", 2) .attr("ry", 2) .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); var msg = vis.append("text") .attr("class", "no-data") .text(textMessage) .attr("text-anchor", "middle") .attr("font-size", "20px") .attr("x", width/2) .attr("y", height/2) .attr("transform", "translate(" + margin.left + "," + margin.top + ")"); // if there is data (even a series of zeros), draw the time-series chart: } else { /* * ======================================================================== * Global variables and options * ======================================================================== */ var metricName = this.metricName; // the format of the date in the input data var input_date_format = d3.time.format("%Y-%m"); // how dates will be displayed in the chart in most cases var display_date_format = d3.time.format("%b %Y"); // the length of a day/year in milliseconds var day_in_ms = 86400000, year_in_ms = 31540000000; // focus chart sizing var margin = {top: 30, right: 30, bottom: 95, left: 20}, height = this.height - margin.top - margin.bottom; //Set the chart width this.$el.css("width", "100%"); var width = (this.$el.width() || this.width) - margin.left - margin.right; this.width = width; // context chart sizing var margin_context = {top: 315, right: 30, bottom: 20, left: 20}, height_context = this.height - margin_context.top - margin_context.bottom; // zoom button sizing var button_width = 40, button_height = 14, button_padding = 10; // how wide does the tooltip div need to be to accomdate text? var tooltip_width = 76; // what proportion of a month should a bar cover? var bar_width_factor = 0.8; /* * ======================================================================== * Prepare data * ======================================================================== */ // change dates to milliseconds, to enable calculating the `d3.extent` var metricMonths_parsed = []; this.metricMonths.forEach(function(part, index, theArray) { try { metricMonths_parsed[index] = input_date_format.parse(part).getTime(); } catch { // replace null with current month var today = new Date(); var yyyy = today.getFullYear(); var mm = today.getMonth() + 1; var updatedPart = yyyy.toString() + "-" + (mm.toString()).padStart(2, '0'); metricMonths_parsed[index] = input_date_format.parse(updatedPart).getTime(); } }); // input data from model doesn't list months where there were 0 counts for all metrics (views/downloads/citations) // construct an array of all months between min and max dates to use as x variable var all_months = d3.time.scale() .domain(d3.extent(metricMonths_parsed)) .ticks(d3.time.months, 1); // add padding to both sides of array so that bars don't get cut off. // add more padding when there's just one bar (otherwise it's too wide) if (metricMonths_parsed.length == 1) { var new_min_date = new Date(d3.extent(metricMonths_parsed)[0] - day_in_ms*13), new_max_date = new Date(d3.extent(metricMonths_parsed)[1] + day_in_ms*31*bar_width_factor + day_in_ms*13); } else { var new_min_date = new Date(d3.extent(metricMonths_parsed)[0] - day_in_ms*1), new_max_date = new Date(d3.extent(metricMonths_parsed)[1] + day_in_ms*31*bar_width_factor); }; all_months.push(new_min_date); // also add a little padding on the left for consistency all_months.push(new_max_date); // for each month, check whether there is a count available, // if so append it, otherwise append zero. var dataset = []; for(var i=0; i" + display_date_format(d.month) + "
" + d.count + " " + convert_metric_name(d.count)) .style("left", (x(d.month) + (modal_width-(width + margin.left + margin.right)) + (bar_width_px/2) - (tooltip_width/2) + "px"))//) + 300 + ((width/dataset.length) * 0.5 * get_zoom_scale())) + "px") .style("top", (y(d.count) + 19) + "px"); d3.select(".metric_tooltip") .transition() .duration(60) .style("opacity", 0.98); } else { d3.select("#user-" + self.id + " > .metric_tooltip") .html("" + display_date_format(d.month) + "
" + d.count + " " + convert_metric_name(d.count)) .style("left", d3.event.pageX - 150 + "px") .style("top", y(d.count) - y(0) - 150 + "px"); d3.select("#user-" + self.id + " > .metric_tooltip") .transition() .duration(60) .style("opacity", 0.98); } }; function hide_tooltip(d) { if (self.viewType === "dataset") { d3.select(".metric_tooltip") .transition() .duration(60) .style("opacity", 0); } else { d3.select("#user-" + self.id + " > .metric_tooltip") .transition() .duration(60) .style("opacity", 0); } }; /* ------------------------------------------------------ TICK FORMATTING FUNCTIONS (focus x-axis) ------------------------------------------------------ */ function generate_ticks(t0, t1, dt) { var label_size_px = 45; var max_total_labels = Math.floor(width / label_size_px); // offset so that labels are at the center of each month. var offset = (day_in_ms*30*bar_width_factor)/2; function step(date, next_step) { date.setMonth(date.getMonth() + next_step); } var time = d3.time.month.floor(t0), time = new Date(time.getTime() + offset), times = [], monthFactors = [1,3,4,12]; while (time < t1) { times.push(new Date(+time)), step(time, 1)}; var timesCopy = times; var i; for(i=0 ; times.length > max_total_labels ; i++){ var times = _.filter(timesCopy, function(d){ return (d.getMonth()) % monthFactors[i] == 0; } ) }; return times; }; function format_months(d){ add_tick_behaviour(); // add tick hover behaviour everytime ticks are re-formatted; var test = (x.domain()[1] - x.domain()[0]) > 132167493818; // when to switch from yyyy to mm-yyyy if(d.getMonth()==0 & test){//if january var yearOnly = d3.time.format("%Y"); return(yearOnly(d)); } else { return(display_date_format(d)) } } /* ------------------------------------------------------ BRUSH & ZOOM BEHAVIOUR ------------------------------------------------------ */ function change_focus_brush() { // make the x domain match the brush domain x.domain(brush.empty() ? x_context.domain() : brush.extent()); // reset zoom zoom.x(x); // re-draw axis and elements at new scale update_focus(); // update the explanatory text (total views, date range) update_context(); } function change_focus_zoom() { // make the brush range change with the x domain in focus brush.extent(x.domain()); vis.select(".brush").call(brush); // re-draw axis and elements at new scale update_focus(); // update the explanatory text (total views, date range) update_context(); } function update_focus() { // calculate where the bar goes out of focus var bar_width_days = bar_width_factor*30.5; var left_date = x.domain()[0]; if(left_date.getDate() < bar_width_days){ var left_date = d3.time.month.floor(left_date), left_date = new Date(left_date.getTime()) }; var data_subset_focus = dataset.filter( function(d) { return d.month <= x.domain()[1] && d.month >= left_date }); var y_max_focus = d3.max(data_subset_focus, function(d) { return d.count; }) * 1.04 || 1.04; var y_change_duration = 85; // reset y-axis y.domain([0, y_max_focus]); focus.select(".y.axis") .transition() .duration(y_change_duration*0.95) .call(y_axis); // reset bar height given y-axis focus.selectAll(".bar") .transition() .duration(y_change_duration) .attr("y", function (d) {return y(d.count); }) .attr("height", function (d) {return y(0) - y(d.count); }); // redraw other elements focus.select(".x.axis").call(x_axis); focus.selectAll(".bar") .attr("x", function (d) {return x(d.month); }) .attr("width", bar_width * get_zoom_scale()) .style("opacity", "1"); // incase user scrolls before entrance animation finishes. }; function update_context() { // updates display dates, total count, and decreases opacity of context bars out of focus var b = brush.extent(); // calculate where the bar goes out of focus var bar_width_days = bar_width_factor*30.5; if(b[0].getDate() >= bar_width_days){ var left_date = d3.time.month.ceil(b[0]), left_date = new Date(left_date.getTime()) } else { left_date = d3.time.month.floor(b[0]) }; // get the range of data in focus // if there's only one data point, make sure start and end month are the same if (metricMonths_parsed.length == 1) { var start_month = display_date_format(new Date(metricMonths_parsed[0])), end_month = start_month; } else { var start_month = (brush.empty()) ? display_date_format(x_full_extent[0]) : display_date_format(left_date), end_month = (brush.empty()) ? display_date_format(x_full_extent[1]) : display_date_format(b[1]); }; var data_subset_focus = dataset.filter(function(d) { if (metricMonths_parsed.length == 1) { return d.month } else { return d.month <= display_date_format.parse(end_month) && d.month >= left_date }; }); // calcualte the total views/downloads within focus area var total_count = 0; for (var i = 0; i < data_subset_focus.length; i++) { total_count += data_subset_focus[i].count; } // Update start and end dates and total count vis.select("#displayDates") .text(start_month == end_month ? "in " + start_month : "from " + start_month + " to " + end_month); vis.select("#totalCount") .text(MetacatUI.appView.commaSeparateNumber(total_count) + " " + convert_metric_name(total_count)); // Fade all years in the bar chart not within the brush context.selectAll(".bar_context") .style("opacity", function(d, i) { if (metricMonths_parsed.length == 1) { return "1"; } else { return d.month <= display_date_format.parse(end_month) && d.month >= left_date || brush.empty() ? "1" : ".3"; } }); }; function check_bounds() { // when brush stops moving: // check whether chart was scrolled out of bounds and fix, var b = brush.extent(); var out_of_bounds = brush.extent().some(function(e) { return e < x_full_extent[0] | e > x_full_extent[1]; }); if (out_of_bounds){ b = move_in_bounds(b) }; }; function move_in_bounds(b) { // move back to boundaries if user pans outside min and max date. var year_in_ms = 31536000000, brush_start_new, brush_end_new; if (b[0] < x_full_extent[0]) { brush_start_new = x_full_extent[0]; } else if (b[0] > x_full_extent[1]) { brush_start_new = x_full_extent[0]; } else { brush_start_new = b[0]; }; if (b[1] > x_full_extent[1]) { brush_end_new = x_full_extent[1]; } else if (b[1] < x_full_extent[0]) { brush_end_new = x_full_extent[1]; } else { brush_end_new = b[1]; }; brush.extent([brush_start_new, brush_end_new]); brush(d3.select("#" + self.id + " > .context > .brush").transition()); change_focus_brush(); change_focus_zoom(); return(brush.extent()) }; function zoom_to_interval(d,i) { // action for buttons that zoom focus to certain time interval var b = brush.extent(), interval_ms, brush_end_new, brush_start_new; if (d == "year") { interval_ms = 31536000000} else if (d == "month") { interval_ms = 2592000000 }; if ( d == "year" | d == "month" ) { if((x_full_extent[1].getTime() - b[1].getTime()) < interval_ms){ // if brush is too far to the right that increasing the right-hand brush boundary would make the chart go out of bounds.... brush_start_new = new Date(x_full_extent[1].getTime() - interval_ms); // ...then decrease the left-hand brush boundary... brush_end_new = x_full_extent[1]; //...and set the right-hand brush boundary to the maxiumum limit. } else { // otherwise, increase the right-hand brush boundary. brush_start_new = b[0]; brush_end_new = new Date(b[0].getTime() + interval_ms); }; } else if ( d == "all") { brush_start_new = x_full_extent[0]; brush_end_new = x_full_extent[1] } else { brush_start_new = b[0]; brush_end_new = b[1]; }; brush.extent([brush_start_new, brush_end_new]); // now draw the brush to match our extent brush(d3.select("#" + self.id + " > .context > .brush").transition()); // now fire the brushstart, brushmove, and check_bounds events brush.event(d3.select("#" + self.id + " > .context > .brush").transition()); }; // that's it! } //Re-render this view when the window is resized this.listenToWindowResize(); return this; }, /** * Adds a listener so when the window is resized, the chart is redrawn */ listenToWindowResize: function(){ if( !this.resizeCallback ){ this.resizeCallback = this.render.bind(this); window.addEventListener('resize', this.resizeCallback, false); } }, /** * Removes the window resize listener set in {@link MetricsChartView#listenToWindowResize} */ stopListenToWindowResize: function(){ //Remove the listener to window resize window.removeEventListener("resize", this.resizeCallback, false); delete this.resizeCallback; }, /** * Cleans up listeners and other artifacts from this view */ onClose: function(){ this.stopListenToWindowResize(); } }); return MetricsChartView; });