diff --git a/README.md b/README.md index bf2f4434421be..42edbf3757cff 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ It empowers its user to perform **analytics at the speed of thought**. Panoramix provides: * A quick way to intuitively visualize datasets -* Create and share simple dashboards +* Create and share interactive dashboards * A rich set of visualizations to analyze your data, as well as a flexible way to extend the capabilities * An extensible, high granularity security model allowing intricate rules diff --git a/TODO.md b/TODO.md index b3e174868d7f0..f144b8635420e 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,7 @@ List of TODO items for Panoramix ## Improvments +* Read dashboard filter from URL * Table description is markdown * Animated scatter plots * Filter widget diff --git a/panoramix/models.py b/panoramix/models.py index e5d5d699862bc..b8d5423515bc9 100644 --- a/panoramix/models.py +++ b/panoramix/models.py @@ -251,6 +251,10 @@ class SqlaTable(Model, Queryable, AuditMixinNullable): def __repr__(self): return self.table_name + @property + def description_markeddown(self): + return utils.markdown(self.description) + @property def perm(self): return ( diff --git a/panoramix/static/panoramix.css b/panoramix/static/panoramix.css index a4968729dda09..73c007df27d2b 100644 --- a/panoramix/static/panoramix.css +++ b/panoramix/static/panoramix.css @@ -2,6 +2,15 @@ html>body{ margin: 0px; !important } +.padded{ + padding: 10px; +} + +.intable-longtext{ + max-height: 200px; + overflow: auto; +} + .slice_container { height: 100%; } diff --git a/panoramix/static/panoramix.js b/panoramix/static/panoramix.js index 29e521a85a63d..cb162696720f6 100644 --- a/panoramix/static/panoramix.js +++ b/panoramix/static/panoramix.js @@ -68,7 +68,6 @@ var px = (function() { $('#timer').removeClass('btn-danger btn-success'); $('#timer').addClass('btn-warning'); viz.render(); - console.log(slice); $('#json').click(function(){window.location=slice.jsonEndpoint()}); $('#standalone').click(function(){window.location=slice.data.standalone_endpoint}); $('#csv').click(function(){window.location=slice.data.csv_endpoint}); @@ -99,9 +98,14 @@ var px = (function() { slices: [], filters: {}, id: id, - addFilter: function(slice_id, field, values) { - this.filters[slice_id] = [field, values]; + addFilter: function(slice_id, filters) { + this.filters[slice_id] = filters; this.refreshExcept(slice_id); + console.log(this.filters); + }, + readFilters: function() { + // Returns a list of human readable active filters + return JSON.stringify(this.filters, null, 4); }, refreshExcept: function(slice_id) { this.slices.forEach(function(slice){ @@ -197,6 +201,7 @@ var px = (function() { function druidify(){ prepForm(); + $('div.alert').remove(); slice.render(); } diff --git a/panoramix/static/widgets/viz_filter_box.css b/panoramix/static/widgets/viz_filter_box.css new file mode 100644 index 0000000000000..e8b38904a14c2 --- /dev/null +++ b/panoramix/static/widgets/viz_filter_box.css @@ -0,0 +1,4 @@ +.select2-highlighted>.filter_box { + background-color: transparent; + border: 1px dashed black; +} diff --git a/panoramix/static/widgets/viz_filter_box.js b/panoramix/static/widgets/viz_filter_box.js new file mode 100644 index 0000000000000..2e7e4dfc00645 --- /dev/null +++ b/panoramix/static/widgets/viz_filter_box.js @@ -0,0 +1,76 @@ +px.registerViz('filter_box', function(slice) { + var slice = slice; + var filtersObj = {}; + d3token = d3.select(slice.selector); + + var fltChanged = function() { + filters = [] + for(flt in filtersObj) { + obj = filtersObj[flt]; + val = obj.val() + if(val !== ''){ + filters.push([flt, val.split(',')]); + } + } + slice.addFilter(filters); + } + + var refresh = function() { + d3token.selectAll("*").remove(); + var container = d3token + .append('div') + .classed('padded', true); + $.getJSON(slice.jsonEndpoint(), function(payload) { + var maxes = {}; + for (filter in payload.data){ + var data = payload.data[filter]; + maxes[filter] = d3.max(data, function(d){return d.metric}); + var id = 'fltbox__' + filter; + + var div = container.append('div'); + div.append("label").text(filter); + var sel = div + .append('div') + .attr('name', filter) + .classed('form-control', true) + .attr('multiple', '') + .attr('id', id); + + filtersObj[filter] = $('#' + id).select2({ + placeholder: "Select [" + filter + ']', + containment: 'parent', + dropdownAutoWidth : true, + data:data, + multiple: true, + formatResult: function(result, container, query, escapeMarkup) { + var perc = Math.round((result.metric / maxes[result.filter]) * 100); + var style = 'padding: 2px 5px;'; + style += "background-image: "; + style += "linear-gradient(to right, lightgrey, lightgrey " + perc + "%, rgba(0,0,0,0) " + perc + "%"; + + $(container).attr('style', 'padding: 0px; background: white;'); + $(container).addClass('filter_box'); + return '
' + result.text + '
'; + }, + }) + .on('change', fltChanged); + /* + .style('background-image', function(d){ + if (d.isMetric){ + var perc = Math.round((d.val / maxes[d.col]) * 100); + return "linear-gradient(to right, lightgrey, lightgrey " + perc + "%, rgba(0,0,0,0) " + perc + "%"; + } + }) + */ + } + slice.done(); + }) + .fail(function(xhr) { + slice.error(xhr.responseText); + }); + }; + return { + render: refresh, + resize: refresh, + }; +}); diff --git a/panoramix/static/widgets/viz_table.js b/panoramix/static/widgets/viz_table.js index 7c83d906b5a46..5baaa67d94b25 100644 --- a/panoramix/static/widgets/viz_table.js +++ b/panoramix/static/widgets/viz_table.js @@ -55,7 +55,7 @@ px.registerViz('table', function(slice) { } else { table.selectAll('.filtered').classed('filtered', false); d3.select(this).classed('filtered', true); - slice.addFilter(d.col, [d.val]); + slice.addFilter([[d.col, [d.val]]]); } } }) diff --git a/panoramix/templates/panoramix/dashboard.html b/panoramix/templates/panoramix/dashboard.html index 9853a92b635c7..c0181a4524df3 100644 --- a/panoramix/templates/panoramix/dashboard.html +++ b/panoramix/templates/panoramix/dashboard.html @@ -54,6 +54,9 @@

+ @@ -88,6 +91,7 @@

+ @@ -124,6 +128,12 @@

$(document).ready(function() { px.initDashboardView(); var dashboard = px.Dashboard({{ dashboard.id }}); + $('#filters').click( function(){ + alert(dashboard.readFilters()); + }); + $('a.bug').click( function(){ + console.log(dashboard.getSlice($(this).data('slice_id'))); + }); }); {% endblock %} diff --git a/panoramix/templates/panoramix/explore.html b/panoramix/templates/panoramix/explore.html index e1181eeaee07c..dbca1763fabe3 100644 --- a/panoramix/templates/panoramix/explore.html +++ b/panoramix/templates/panoramix/explore.html @@ -182,10 +182,10 @@

- +

- + @@ -16,9 +16,11 @@

Featured Datasets

{% for dataset in featured_datasets %} - diff --git a/panoramix/utils.py b/panoramix/utils.py index bc88faebf435f..c9f6d38d561a8 100644 --- a/panoramix/utils.py +++ b/panoramix/utils.py @@ -1,11 +1,14 @@ from datetime import datetime -from dateutil.parser import parse +import functools import hashlib -from sqlalchemy.types import TypeDecorator, TEXT import json + +from dateutil.parser import parse +from sqlalchemy.types import TypeDecorator, TEXT from flask import g, request, Markup +from markdown import markdown as md import parsedatetime -import functools + from panoramix import db @@ -222,3 +225,7 @@ def json_iso_dttm_ser(obj): if isinstance(obj, datetime): obj = obj.isoformat() return obj + + +def markdown(s): + return md(s, ['markdown.extensions.tables']) diff --git a/panoramix/views.py b/panoramix/views.py index 4c99a12feaaaf..8cefc136d3a8f 100644 --- a/panoramix/views.py +++ b/panoramix/views.py @@ -4,7 +4,7 @@ import re import traceback -from flask import request, redirect, flash, Response, render_template +from flask import request, redirect, flash, Response, render_template, Markup from flask.ext.appbuilder import ModelView, CompactCRUDMixin, BaseView, expose from flask.ext.appbuilder.actions import action from flask.ext.appbuilder.models.sqla.interface import SQLAInterface @@ -153,7 +153,8 @@ class TableView(PanoramixModelView, DeleteMixin): related_views = [TableColumnInlineView, SqlMetricInlineView] base_order = ('changed_on','desc') description_columns = { - 'offset': "Timezone offset (in hours) for this datasource" + 'offset': "Timezone offset (in hours) for this datasource", + 'description': Markup("Supports markdown"), } def post_add(self, table): @@ -281,7 +282,8 @@ class DatasourceModelView(PanoramixModelView, DeleteMixin): page_size = 100 base_order = ('datasource_name', 'asc') description_columns = { - 'offset': "Timezone offset (in hours) for this datasource" + 'offset': "Timezone offset (in hours) for this datasource", + 'description': Markup("Supports markdown"), } def post_add(self, datasource): @@ -564,7 +566,8 @@ def featured_datasets(self): featured_datasets = datasets_sqla + datasets_druid return self.render_template( 'panoramix/featured_datasets.html', - featured_datasets=featured_datasets) + featured_datasets=featured_datasets, + utils=utils) appbuilder.add_view_no_menu(Panoramix) appbuilder.add_link( diff --git a/panoramix/viz.py b/panoramix/viz.py index 1fed212c5faa9..c8747fae2c19f 100644 --- a/panoramix/viz.py +++ b/panoramix/viz.py @@ -161,8 +161,11 @@ def query_filters(self): extra_filters = form_data.get('extra_filters', []) if extra_filters: extra_filters = json.loads(extra_filters) - for slice_id, (col, vals) in extra_filters.items(): - filters += [(col, 'in', ",".join(vals))] + for slice_id, slice_filters in extra_filters.items(): + if slice_filters: + for col, vals in slice_filters: + if col and vals: + filters += [(col, 'in', ",".join(vals))] return filters @@ -1105,6 +1108,61 @@ def get_json_data(self): return dumps(d) +class FilterBoxViz(BaseViz): + viz_type = "filter_box" + verbose_name = "Filters" + is_timeseries = False + js_files = [ + 'lib/d3.min.js', + 'widgets/viz_filter_box.js'] + css_files = [ + 'widgets/viz_filter_box.css'] + fieldsets = ( + { + 'label': None, + 'fields': ( + 'granularity', + ('since', 'until'), + 'groupby', + 'metric', + ) + },) + form_overrides = { + 'groupby': { + 'label': 'Filter fields', + 'description': "The fields you want to filter on", + }, + } + def query_obj(self): + qry = super(FilterBoxViz, self).query_obj() + groupby = self.form_data['groupby'] + if len(groupby) < 1: + raise Exception("Pick at least one filter field") + qry['metrics'] = [ + self.form_data['metric']] + return qry + + def get_df(self): + qry = self.query_obj() + + filters = [g for g in qry['groupby']] + d = {} + for flt in filters: + qry['groupby'] = [flt] + df = super(FilterBoxViz, self).get_df(qry) + d[flt] = [ + {'id': row[0], + 'text': row[0], + 'filter': flt, + 'metric': row[1]} + for row in df.itertuples(index=False)] + return d + + def get_json_data(self): + d = self.get_df() + return dumps(d) + + viz_types_list = [ TableViz, PivotTableViz, @@ -1122,6 +1180,7 @@ def get_json_data(self): DirectedForceViz, SankeyViz, WorldMapViz, + FilterBoxViz, ] # This dict is used to viz_types = OrderedDict([(v.viz_type, v) for v in viz_types_list])