diff --git a/caravel/assets/images/viz_thumbnails/histogram.png b/caravel/assets/images/viz_thumbnails/histogram.png new file mode 100644 index 000000000..f7bbe6240 Binary files /dev/null and b/caravel/assets/images/viz_thumbnails/histogram.png differ diff --git a/caravel/assets/javascripts/modules/caravel.js b/caravel/assets/javascripts/modules/caravel.js index de7f5899f..f19b5a66f 100644 --- a/caravel/assets/javascripts/modules/caravel.js +++ b/caravel/assets/javascripts/modules/caravel.js @@ -32,6 +32,7 @@ const sourceMap = { cal_heatmap: 'cal_heatmap.js', horizon: 'horizon.js', mapbox: 'mapbox.jsx', + histogram: 'histogram.js', }; const color = function () { // Color related utility functions go in this object diff --git a/caravel/assets/visualizations/histogram.css b/caravel/assets/visualizations/histogram.css new file mode 100644 index 000000000..05fc08651 --- /dev/null +++ b/caravel/assets/visualizations/histogram.css @@ -0,0 +1,17 @@ +.axis line { + fill: none; + stroke: black; + shape-rendering: crispEdges; +} + +.axis text { + font-family: "Roboto", "Helvetica Neue", Helvetica, Arial, sans-serif; + font-weight: bold; + font-size: 15px; +} + +.axis path, .axis line { + fill: none; + stroke: #000; + shape-rendering: crispEdges; +} diff --git a/caravel/assets/visualizations/histogram.js b/caravel/assets/visualizations/histogram.js new file mode 100644 index 000000000..10b6aea7a --- /dev/null +++ b/caravel/assets/visualizations/histogram.js @@ -0,0 +1,152 @@ +// JS +const d3 = require('d3') +const px = window.px || require('../javascripts/modules/caravel.js') + +// CSS +require('./histogram.css') + +function histogram(slice) { + + const div = d3.select(slice.selector) + + const _draw = function(data, numBins) { + + // Set Margins + const margin = { + top: 50, + right: 10, + bottom: 20, + left: 50, + }; + const navBarHeight = 36; + const navBarTitleSize = 12; + const navBarBuffer = 10; + const width = slice.width() - margin.left - margin.right; + const height = slice.height() - margin.top - margin.bottom - navBarHeight - navBarBuffer; + + // Set Histogram objects + const formatNumber = d3.format(',.0f'); + const formatTicks = d3.format(',.00f'); + const x = d3.scale.ordinal(); + const y = d3.scale.linear(); + const xAxis = d3.svg.axis().scale(x).orient('bottom').ticks(numBins).tickFormat(formatTicks); + const yAxis = d3.svg.axis().scale(y).orient('left').ticks(numBins*3); + // Calculate bins for the data + const bins = d3.layout.histogram().bins(numBins)(data); + + // Set the x-values + x.domain(bins.map(function(d) { return d.x;})) + .rangeRoundBands([0, width], .1); + // Set the y-values + y.domain([0, d3.max(bins, function(d) { return d.y;})]) + .range([height, 0]); + + // Create the svg value with the bins + const svg = div.selectAll('svg').data([bins]).enter().append('svg'); + + // Make a rectangular background fill + svg.append('rect') + .attr('width', '100%') + .attr('height', '100%') + .attr('fill', '#f6f6f6'); + + // Transform the svg to make space for the margins + const gEnter = svg + .append('g') + .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); + + // Add the bars and the x axis + gEnter.append('g').attr('class', 'bars'); + gEnter.append('g').attr('class', 'x axis'); + + // Add width and height to the svg + svg.attr('width', slice.width()) + .attr('height', slice.height()); + + // Create the bars in the svg + const bar = svg.select('.bars').selectAll('.bar').data(bins); + bar.enter().append('rect'); + bar.exit().remove(); + // Set the Height and Width for each bar + bar .attr('width', x.rangeBand()) + .attr('x', function(d) { return x(d.x); }) + .attr('y', function(d) { return y(d.y); }) + .attr('height', function(d) { + return y.range()[0] - y(d.y); + }) + .attr('fill', function(d) { return px.color.category21(d.length); }) + .order(); + + // Find maximum length to position the ticks on top of the bar correctly + const maxLength = d3.max(bins, function(d) { return d.length;}); + function textAboveBar(d) { + return d.length/maxLength < 0.1; + } + + // Add a bar text to each bar in the histogram + svg.selectAll('.bartext') + .data(bins) + .enter() + .append('text') + .attr('dy', '.75em') + .attr('y', function(d) { + let padding = 0.0 + if (textAboveBar(d)) { + padding = 12.0 + } else { + padding = -8.0 + } + return y(d.y) - padding; + }) + .attr('x', function(d) { return x(d.x) + (x.rangeBand()/2);}) + .attr('text-anchor', 'middle') + .attr('font-weight', 'bold') + .attr('font-size', '15px') + .text(function(d) { return formatNumber(d.y); }) + .attr('fill', function(d) { + if(textAboveBar(d)) { return 'black'; } else { return 'white'; } + }) + .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'); + + // Update the x-axis + svg.append('g') + .attr('class', 'axis') + .attr('transform', 'translate(' + margin.left + ',' + (height + margin.top) + ')') + .text('values') + .call(xAxis); + + // Update the Y Axis and add minor lines + svg.append('g') + .attr('class', 'axis') + .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')') + .text('count') + .call(yAxis) + .selectAll('g') + .filter(function(d) { return d; }) + .classed('minor', true); + }; + + const render = function() { + + d3.json(slice.jsonEndpoint(), function(error, json) { + if(error !== null) { + slice.error(error.responseText, error); + return ''; + } + + const numBins = Number(json.form_data.link_length) || 10; + + div.selectAll('*').remove(); + _draw(json.data, numBins); + slice.done(json); + }); + }; + + return { + render: render, + resize: render, + }; +} + +module.exports = histogram; + diff --git a/caravel/viz.py b/caravel/viz.py index 13449b720..57ef270a1 100755 --- a/caravel/viz.py +++ b/caravel/viz.py @@ -1201,6 +1201,74 @@ class DistributionPieViz(NVD3Viz): return df.to_dict(orient="records") +class HistogramViz(BaseViz): + + """Histogram""" + + viz_type = "histogram" + verbose_name = _("Histogram") + is_timeseries = False + fieldsets = ({ + 'label': None, + 'fields': ( + ('all_columns_x',), + 'row_limit', + ) + }, { + 'label': _("Histogram Options"), + 'fields': ( + 'link_length', + ) + },) + + form_overrides = { + 'all_columns_x': { + 'label': _('Numeric Column'), + 'description': _("Select the numeric column to draw the histogram"), + }, + 'link_length': { + 'label': _("No of Bins"), + 'description': _("Select number of bins for the histogram"), + 'default': 5 + } + } + + + def query_obj(self): + """Returns the query object for this visualization""" + d = super(HistogramViz, self).query_obj() + d['row_limit'] = self.form_data.get('row_limit', int(config.get('ROW_LIMIT'))) + numeric_column = self.form_data.get('all_columns_x') + if numeric_column is None: + raise Exception("Must have one numeric column specified") + d['columns'] = [numeric_column] + return d + + + def get_df(self, query_obj=None): + """Returns a pandas dataframe based on the query object""" + if not query_obj: + query_obj = self.query_obj() + + self.results = self.datasource.query(**query_obj) + self.query = self.results.query + df = self.results.df + + if df is None or df.empty: + raise Exception("No data, to build histogram") + + df.replace([np.inf, -np.inf], np.nan) + df = df.fillna(0) + return df + + + def get_data(self): + """Returns the chart data""" + df = self.get_df() + chart_data = df[df.columns[0]].values.tolist() + return chart_data + + class DistributionBarViz(DistributionPieViz): """A good old bar chart""" @@ -1921,6 +1989,7 @@ viz_types_list = [ CalHeatmapViz, HorizonViz, MapboxViz, + HistogramViz, SeparatorViz, ] diff --git a/docs/gallery.rst b/docs/gallery.rst index 9c7ee0b4e..e25cb1928 100644 --- a/docs/gallery.rst +++ b/docs/gallery.rst @@ -81,3 +81,6 @@ Gallery .. image:: _static/img/viz_thumbnails/separator.png :scale: 25 % + +.. image:: _static/img/viz_thumbnails/histogram.png + :scale: 25 %