mirror of https://github.com/apache/superset.git
Improve the calendar heatmap (#4800)
* Improve xAxis ticks, thinner bottom margin (#4756) * Improve xAxis ticks, thinner bottom margin * Moving utils folder * Add isTruthy * Addressing comments * Set cell_padding to 0 * merging db migrations
This commit is contained in:
parent
b04359003e
commit
6fd4ff45ea
|
@ -153,6 +153,10 @@ class Chart extends React.PureComponent {
|
|||
this.props.actions.chartRenderingFailed(e, this.props.chartKey);
|
||||
}
|
||||
|
||||
verboseMetricName(metric) {
|
||||
return this.props.datasource.verbose_map[metric] || metric;
|
||||
}
|
||||
|
||||
render_template(s) {
|
||||
const context = {
|
||||
width: this.width(),
|
||||
|
|
|
@ -230,6 +230,7 @@ export const controls = {
|
|||
default: colorPrimary,
|
||||
renderTrigger: true,
|
||||
},
|
||||
|
||||
legend_position: {
|
||||
label: t('Legend Position'),
|
||||
description: t('Choose the position of the legend'),
|
||||
|
@ -324,11 +325,17 @@ export const controls = {
|
|||
label: t('Linear Color Scheme'),
|
||||
choices: [
|
||||
['fire', 'fire'],
|
||||
['blue_white_yellow', 'blue/white/yellow'],
|
||||
['white_black', 'white/black'],
|
||||
['black_white', 'black/white'],
|
||||
['dark_blue', 'light/dark blue'],
|
||||
['pink_grey', 'pink/white/grey'],
|
||||
['greens', 'greens'],
|
||||
['purples', 'purples'],
|
||||
['oranges', 'oranges'],
|
||||
['blue_white_yellow', 'blue/white/yellow'],
|
||||
['red_yellow_blue', 'red/yellowish/blue'],
|
||||
['brown_white_green', 'brown/white/green'],
|
||||
['purple_white_green', 'purple/white/green'],
|
||||
],
|
||||
default: 'blue_white_yellow',
|
||||
clearable: false,
|
||||
|
@ -1006,6 +1013,46 @@ export const controls = {
|
|||
'relative to the time granularity selected'),
|
||||
},
|
||||
|
||||
cell_size: {
|
||||
type: 'TextControl',
|
||||
isInt: true,
|
||||
default: 10,
|
||||
validators: [v.integer],
|
||||
renderTrigger: true,
|
||||
label: t('Cell Size'),
|
||||
description: t('The size of the square cell, in pixels'),
|
||||
},
|
||||
|
||||
cell_padding: {
|
||||
type: 'TextControl',
|
||||
isInt: true,
|
||||
validators: [v.integer],
|
||||
renderTrigger: true,
|
||||
default: 0,
|
||||
label: t('Cell Padding'),
|
||||
description: t('The distance between cells, in pixels'),
|
||||
},
|
||||
|
||||
cell_radius: {
|
||||
type: 'TextControl',
|
||||
isInt: true,
|
||||
validators: [v.integer],
|
||||
renderTrigger: true,
|
||||
default: 0,
|
||||
label: t('Cell Radius'),
|
||||
description: t('The pixel radius'),
|
||||
},
|
||||
|
||||
steps: {
|
||||
type: 'TextControl',
|
||||
isInt: true,
|
||||
validators: [v.integer],
|
||||
renderTrigger: true,
|
||||
default: 10,
|
||||
label: t('Color Steps'),
|
||||
description: t('The number color "steps"'),
|
||||
},
|
||||
|
||||
grid_size: {
|
||||
type: 'TextControl',
|
||||
label: t('Grid Size'),
|
||||
|
@ -1462,6 +1509,14 @@ export const controls = {
|
|||
description: t('Whether to display the numerical values within the cells'),
|
||||
},
|
||||
|
||||
show_metric_name: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Show Metric Names'),
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: t('Whether to display the metric name as a title'),
|
||||
},
|
||||
|
||||
x_axis_showminmax: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('X bounds'),
|
||||
|
|
|
@ -977,17 +977,34 @@ export const visTypes = {
|
|||
label: t('Query'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
['metric'],
|
||||
['domain_granularity', 'subdomain_granularity'],
|
||||
['metrics'],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Options'),
|
||||
label: t('Chart Options'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
['domain_granularity'],
|
||||
['subdomain_granularity'],
|
||||
['linear_color_scheme'],
|
||||
['cell_size', 'cell_padding'],
|
||||
['cell_radius', 'steps'],
|
||||
['y_axis_format', 'x_axis_time_format'],
|
||||
['show_legend', 'show_values'],
|
||||
['show_metric_name', null],
|
||||
],
|
||||
},
|
||||
],
|
||||
controlOverrides: {
|
||||
y_axis_format: {
|
||||
label: t('Number Format'),
|
||||
},
|
||||
x_axis_time_format: {
|
||||
label: t('Time Format'),
|
||||
},
|
||||
show_values: {
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
box_plot: {
|
||||
|
|
|
@ -122,6 +122,42 @@ export const spectrums = {
|
|||
'#FAFAFA',
|
||||
'#666666',
|
||||
],
|
||||
greens: [
|
||||
'#ffffcc',
|
||||
'#78c679',
|
||||
'#006837',
|
||||
],
|
||||
purples: [
|
||||
'#f2f0f7',
|
||||
'#9e9ac8',
|
||||
'#54278f',
|
||||
],
|
||||
oranges: [
|
||||
'#fef0d9',
|
||||
'#fc8d59',
|
||||
'#b30000',
|
||||
],
|
||||
red_yellow_blue: [
|
||||
'#d7191c',
|
||||
'#fdae61',
|
||||
'#ffffbf',
|
||||
'#abd9e9',
|
||||
'#2c7bb6',
|
||||
],
|
||||
brown_white_green: [
|
||||
'#a6611a',
|
||||
'#dfc27d',
|
||||
'#f5f5f5',
|
||||
'#80cdc1',
|
||||
'#018571',
|
||||
],
|
||||
purple_white_green: [
|
||||
'#7b3294',
|
||||
'#c2a5cf',
|
||||
'#f7f7f7',
|
||||
'#a6dba0',
|
||||
'#008837',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -48,7 +48,6 @@
|
|||
"bootstrap-slider": "^10.0.0",
|
||||
"brace": "^0.10.0",
|
||||
"brfs": "^1.4.3",
|
||||
"cal-heatmap": "3.6.2",
|
||||
"classnames": "^2.2.5",
|
||||
"d3": "^3.5.17",
|
||||
"d3-cloud": "^1.2.1",
|
||||
|
|
|
@ -0,0 +1,145 @@
|
|||
/* Cal-HeatMap CSS */
|
||||
|
||||
.cal-heatmap-container {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cal-heatmap-container .graph
|
||||
{
|
||||
font-family: "Lucida Grande", Lucida, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.cal-heatmap-container .graph-label
|
||||
{
|
||||
fill: #999;
|
||||
font-size: 10px
|
||||
}
|
||||
|
||||
.cal-heatmap-container .graph, .cal-heatmap-container .graph-legend rect {
|
||||
shape-rendering: crispedges
|
||||
}
|
||||
|
||||
.cal-heatmap-container .graph-rect
|
||||
{
|
||||
fill: #ededed
|
||||
}
|
||||
|
||||
.cal-heatmap-container .graph-subdomain-group rect:hover
|
||||
{
|
||||
stroke: #000;
|
||||
stroke-width: 1px
|
||||
}
|
||||
|
||||
.cal-heatmap-container .subdomain-text {
|
||||
font-size: 8px;
|
||||
fill: #999;
|
||||
pointer-events: none
|
||||
}
|
||||
|
||||
.cal-heatmap-container .hover_cursor:hover {
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
.cal-heatmap-container .qi {
|
||||
background-color: #999;
|
||||
fill: #999
|
||||
}
|
||||
|
||||
/*
|
||||
Remove comment to apply this style to date with value equal to 0
|
||||
.q0
|
||||
{
|
||||
background-color: #fff;
|
||||
fill: #fff;
|
||||
stroke: #ededed
|
||||
}
|
||||
*/
|
||||
|
||||
.cal-heatmap-container .q1
|
||||
{
|
||||
background-color: #dae289;
|
||||
fill: #dae289
|
||||
}
|
||||
|
||||
.cal-heatmap-container .q2
|
||||
{
|
||||
background-color: #cedb9c;
|
||||
fill: #9cc069
|
||||
}
|
||||
|
||||
.cal-heatmap-container .q3
|
||||
{
|
||||
background-color: #b5cf6b;
|
||||
fill: #669d45
|
||||
}
|
||||
|
||||
.cal-heatmap-container .q4
|
||||
{
|
||||
background-color: #637939;
|
||||
fill: #637939
|
||||
}
|
||||
|
||||
.cal-heatmap-container .q5
|
||||
{
|
||||
background-color: #3b6427;
|
||||
fill: #3b6427
|
||||
}
|
||||
|
||||
.cal-heatmap-container rect.highlight
|
||||
{
|
||||
stroke:#444;
|
||||
stroke-width:1
|
||||
}
|
||||
|
||||
.cal-heatmap-container text.highlight
|
||||
{
|
||||
fill: #444
|
||||
}
|
||||
|
||||
.cal-heatmap-container rect.highlight-now
|
||||
{
|
||||
stroke: red
|
||||
}
|
||||
|
||||
.cal-heatmap-container text.highlight-now
|
||||
{
|
||||
fill: red;
|
||||
font-weight: 800
|
||||
}
|
||||
|
||||
.cal-heatmap-container .domain-background {
|
||||
fill: none;
|
||||
shape-rendering: crispedges
|
||||
}
|
||||
|
||||
.ch-tooltip {
|
||||
padding: 10px;
|
||||
background: #222;
|
||||
color: #bbb;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
width: 140px;
|
||||
position: absolute;
|
||||
z-index: 99999;
|
||||
text-align: center;
|
||||
border-radius: 2px;
|
||||
box-shadow: 2px 2px 2px rgba(0,0,0,0.2);
|
||||
display: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ch-tooltip::after{
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-color: transparent;
|
||||
border-style: solid;
|
||||
content: "";
|
||||
padding: 0;
|
||||
display: block;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
border-width: 6px 6px 0;
|
||||
border-top-color: #222;
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -1,9 +1,14 @@
|
|||
.cal_heatmap .slice_container {
|
||||
.slice_container.cal_heatmap {
|
||||
padding: 10px;
|
||||
position: static !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
|
||||
.cal_heatmap .slice_container .ch-tooltip {
|
||||
margin-left: 20px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.graph-legend rect {
|
||||
stroke: #aaa;
|
||||
stroke-location: inside;
|
||||
}
|
||||
|
|
|
@ -1,38 +1,83 @@
|
|||
// JS
|
||||
import d3 from 'd3';
|
||||
|
||||
// CSS
|
||||
require('./cal_heatmap.css');
|
||||
require('../node_modules/cal-heatmap/cal-heatmap.css');
|
||||
import { colorScalerFactory } from '../javascripts/modules/colors';
|
||||
import CalHeatMap from '../vendor/cal-heatmap/cal-heatmap';
|
||||
import '../vendor/cal-heatmap/cal-heatmap.css';
|
||||
import { d3TimeFormatPreset, d3FormatPreset } from '../javascripts/modules/utils';
|
||||
import './cal_heatmap.css';
|
||||
import { UTC } from '../javascripts/modules/dates';
|
||||
|
||||
const CalHeatMap = require('cal-heatmap');
|
||||
const UTCTS = uts => UTC(new Date(uts)).getTime();
|
||||
|
||||
function calHeatmap(slice, payload) {
|
||||
const div = d3.select(slice.selector);
|
||||
const fd = slice.formData;
|
||||
const steps = fd.steps;
|
||||
const valueFormatter = d3FormatPreset(fd.y_axis_format);
|
||||
const timeFormatter = d3TimeFormatPreset(fd.x_axis_time_format);
|
||||
|
||||
const container = d3.select(slice.selector).style('height', slice.height());
|
||||
container.selectAll('*').remove();
|
||||
const div = container.append('div');
|
||||
const data = payload.data;
|
||||
|
||||
div.selectAll('*').remove();
|
||||
const cal = new CalHeatMap();
|
||||
const subDomainTextFormat = fd.show_values ? (date, value) => valueFormatter(value) : null;
|
||||
const cellPadding = fd.cell_padding !== '' ? fd.cell_padding : 2;
|
||||
const cellRadius = fd.cell_radius || 0;
|
||||
const cellSize = fd.cell_size || 10;
|
||||
|
||||
const timestamps = data.timestamps;
|
||||
const extents = d3.extent(Object.keys(timestamps), key => timestamps[key]);
|
||||
const step = (extents[1] - extents[0]) / 5;
|
||||
// Trick to convert all timestamps to UTC
|
||||
const metricsData = {};
|
||||
Object.keys(data.data).forEach((metric) => {
|
||||
metricsData[metric] = {};
|
||||
Object.keys(data.data[metric]).forEach((ts) => {
|
||||
metricsData[metric][UTCTS(ts * 1000) / 1000] = data.data[metric][ts];
|
||||
});
|
||||
});
|
||||
|
||||
Object.keys(metricsData).forEach((metric) => {
|
||||
const calContainer = div.append('div');
|
||||
if (fd.show_metric_name) {
|
||||
calContainer.append('h4').text(slice.verboseMetricName(metric));
|
||||
}
|
||||
const timestamps = metricsData[metric];
|
||||
const extents = d3.extent(Object.keys(timestamps), key => timestamps[key]);
|
||||
const step = (extents[1] - extents[0]) / (steps - 1);
|
||||
const colorScale = colorScalerFactory(fd.linear_color_scheme, null, null, extents);
|
||||
|
||||
const legend = d3.range(steps).map(i => extents[0] + (step * i));
|
||||
const legendColors = legend.map(colorScale);
|
||||
|
||||
const cal = new CalHeatMap();
|
||||
|
||||
try {
|
||||
cal.init({
|
||||
start: data.start,
|
||||
start: UTCTS(data.start),
|
||||
data: timestamps,
|
||||
itemSelector: slice.selector,
|
||||
itemSelector: calContainer[0][0],
|
||||
legendVerticalPosition: 'top',
|
||||
cellSize,
|
||||
cellPadding,
|
||||
cellRadius,
|
||||
legendCellSize: cellSize,
|
||||
legendCellPadding: 2,
|
||||
legendCellRadius: cellRadius,
|
||||
tooltip: true,
|
||||
domain: data.domain,
|
||||
subDomain: data.subdomain,
|
||||
range: data.range,
|
||||
browsing: true,
|
||||
legend: [extents[0], extents[0] + step, extents[0] + (step * 2), extents[0] + (step * 3)],
|
||||
legend,
|
||||
legendColors: {
|
||||
colorScale,
|
||||
min: legendColors[0],
|
||||
max: legendColors[legendColors.length - 1],
|
||||
empty: 'white',
|
||||
},
|
||||
displayLegend: fd.show_legend,
|
||||
itemName: '',
|
||||
valueFormatter,
|
||||
timeFormatter,
|
||||
subDomainTextFormat,
|
||||
});
|
||||
} catch (e) {
|
||||
slice.error(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = calHeatmap;
|
||||
|
|
|
@ -1761,12 +1761,6 @@ cacache@^10.0.1:
|
|||
unique-filename "^1.1.0"
|
||||
y18n "^3.2.1"
|
||||
|
||||
cal-heatmap@3.6.2:
|
||||
version "3.6.2"
|
||||
resolved "https://registry.yarnpkg.com/cal-heatmap/-/cal-heatmap-3.6.2.tgz#961a7f4686b3bdcf7104d951b6ff1dd58c0c62d1"
|
||||
dependencies:
|
||||
d3 "^3.0.6"
|
||||
|
||||
call-matcher@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/call-matcher/-/call-matcher-1.0.1.tgz#5134d077984f712a54dad3cbf62de28dce416ca8"
|
||||
|
@ -2652,7 +2646,7 @@ d3-zoom@^1.3.0:
|
|||
d3-selection "1"
|
||||
d3-transition "1"
|
||||
|
||||
d3@3, d3@^3.0.6, d3@^3.5.17, d3@^3.5.5, d3@^3.5.6:
|
||||
d3@3, d3@^3.5.17, d3@^3.5.5, d3@^3.5.6:
|
||||
version "3.5.17"
|
||||
resolved "https://registry.yarnpkg.com/d3/-/d3-3.5.17.tgz#bc46748004378b21a360c9fc7cf5231790762fb8"
|
||||
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 5ccf602336a0
|
||||
Revises: ('130915240929', 'c9495751e314')
|
||||
Create Date: 2018-04-12 16:00:47.639218
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '5ccf602336a0'
|
||||
down_revision = ('130915240929', 'c9495751e314')
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def upgrade():
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
|
@ -0,0 +1,56 @@
|
|||
"""cal_heatmap_metric_to_metrics
|
||||
|
||||
Revision ID: bf706ae5eb46
|
||||
Revises: f231d82b9b26
|
||||
Create Date: 2018-04-10 11:19:47.621878
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import json
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy import Column, Integer, String, Text
|
||||
|
||||
from superset import db
|
||||
from superset.legacy import cast_form_data
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'bf706ae5eb46'
|
||||
down_revision = 'f231d82b9b26'
|
||||
|
||||
|
||||
class Slice(Base):
|
||||
"""Declarative class to do query in upgrade"""
|
||||
__tablename__ = 'slices'
|
||||
id = Column(Integer, primary_key=True)
|
||||
datasource_type = Column(String(200))
|
||||
viz_type = Column(String(200))
|
||||
slice_name = Column(String(200))
|
||||
params = Column(Text)
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
|
||||
slices = session.query(Slice).filter_by(viz_type='cal_heatmap').all()
|
||||
slice_len = len(slices)
|
||||
for i, slc in enumerate(slices):
|
||||
try:
|
||||
params = json.loads(slc.params or '{}')
|
||||
params['metrics'] = [params.get('metric')]
|
||||
del params['metric']
|
||||
slc.params = json.dumps(params, indent=2, sort_keys=True)
|
||||
session.merge(slc)
|
||||
session.commit()
|
||||
print('Upgraded ({}/{}): {}'.format(i, slice_len, slc.slice_name))
|
||||
except Exception as e:
|
||||
print(slc.slice_name + ' error: ' + str(e))
|
||||
|
||||
session.close()
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
|
@ -0,0 +1,22 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: c9495751e314
|
||||
Revises: ('30bb17c0dc76', 'bf706ae5eb46')
|
||||
Create Date: 2018-04-10 20:46:57.890773
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c9495751e314'
|
||||
down_revision = ('30bb17c0dc76', 'bf706ae5eb46')
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def upgrade():
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
|
@ -5,16 +5,15 @@ Revises: e68c4473c581
|
|||
Create Date: 2018-03-20 19:47:54.991259
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f231d82b9b26'
|
||||
down_revision = 'e68c4473c581'
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
from superset.utils import generic_find_uq_constraint_name
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f231d82b9b26'
|
||||
down_revision = 'e68c4473c581'
|
||||
|
||||
conv = {
|
||||
'uq': 'uq_%(table_name)s_%(column_0_name)s',
|
||||
}
|
||||
|
@ -44,8 +43,12 @@ def upgrade():
|
|||
[column, 'datasource_id'],
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
bind = op.get_bind()
|
||||
insp = sa.engine.reflection.Inspector.from_engine(bind)
|
||||
|
||||
# Restore the size of the metric_name column.
|
||||
with op.batch_alter_table('metrics', naming_convention=conv) as batch_op:
|
||||
batch_op.alter_column(
|
||||
|
|
|
@ -735,12 +735,18 @@ class CalHeatmapViz(BaseViz):
|
|||
def get_data(self, df):
|
||||
form_data = self.form_data
|
||||
|
||||
df.columns = ['timestamp', 'metric']
|
||||
timestamps = {str(obj['timestamp'].value / 10**9):
|
||||
obj.get('metric') for obj in df.to_dict('records')}
|
||||
data = {}
|
||||
records = df.to_dict('records')
|
||||
for metric in self.metrics:
|
||||
data[metric] = {
|
||||
str(obj[DTTM_ALIAS].value / 10**9): obj.get(metric)
|
||||
for obj in records
|
||||
}
|
||||
|
||||
start = utils.parse_human_datetime(form_data.get('since'))
|
||||
end = utils.parse_human_datetime(form_data.get('until'))
|
||||
if not start or not end:
|
||||
raise Exception("Please provide both time bounds (Since and Until)")
|
||||
domain = form_data.get('domain_granularity')
|
||||
diff_delta = rdelta.relativedelta(end, start)
|
||||
diff_secs = (end - start).total_seconds()
|
||||
|
@ -757,7 +763,7 @@ class CalHeatmapViz(BaseViz):
|
|||
range_ = diff_secs // (60 * 60) + 1
|
||||
|
||||
return {
|
||||
'timestamps': timestamps,
|
||||
'data': data,
|
||||
'start': start,
|
||||
'domain': domain,
|
||||
'subdomain': form_data.get('subdomain_granularity'),
|
||||
|
@ -765,9 +771,10 @@ class CalHeatmapViz(BaseViz):
|
|||
}
|
||||
|
||||
def query_obj(self):
|
||||
qry = super(CalHeatmapViz, self).query_obj()
|
||||
qry['metrics'] = [self.form_data['metric']]
|
||||
return qry
|
||||
d = super(CalHeatmapViz, self).query_obj()
|
||||
fd = self.form_data
|
||||
d['metrics'] = fd.get('metrics')
|
||||
return d
|
||||
|
||||
|
||||
class NVD3Viz(BaseViz):
|
||||
|
|
Loading…
Reference in New Issue