Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 33 additions & 7 deletions plotly/colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,21 +442,37 @@ def make_colorscale(colors, scale=None):
return colorscale


def find_intermediate_color(lowcolor, highcolor, intermed):
def find_intermediate_color(lowcolor, highcolor, intermed, colortype='tuple'):
"""
Returns the color at a given distance between two colors

This function takes two color tuples, where each element is between 0
and 1, along with a value 0 < intermed < 1 and returns a color that is
intermed-percent from lowcolor to highcolor
intermed-percent from lowcolor to highcolor. If colortype is set to 'rgb',
the function will automatically convert the rgb type to a tuple, find the
intermediate color and return it as an rgb color.
"""
if colortype == 'rgb':
# convert to tuple
lowcolor = unlabel_rgb(lowcolor)
highcolor = unlabel_rgb(highcolor)

diff_0 = float(highcolor[0] - lowcolor[0])
diff_1 = float(highcolor[1] - lowcolor[1])
diff_2 = float(highcolor[2] - lowcolor[2])

return (lowcolor[0] + intermed * diff_0,
lowcolor[1] + intermed * diff_1,
lowcolor[2] + intermed * diff_2)
inter_med_tuple = (
lowcolor[0] + intermed * diff_0,
lowcolor[1] + intermed * diff_1,
lowcolor[2] + intermed * diff_2
)

if colortype == 'rgb':
# covert back to rgb
inter_med_rgb = label_rgb(inter_med_tuple)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

by rgb do you mean like a string like rgb(30, 20, 10)? if so, could we say convert back to an rgb string, e.g. rgb(30, 20, 10)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that's what I mean. More clearer your way.

return inter_med_rgb

return inter_med_tuple


def unconvert_from_RGB_255(colors):
Expand Down Expand Up @@ -498,14 +514,20 @@ def convert_to_RGB_255(colors):
return (rgb_components[0], rgb_components[1], rgb_components[2])


def n_colors(lowcolor, highcolor, n_colors):
def n_colors(lowcolor, highcolor, n_colors, colortype='tuple'):
"""
Splits a low and high color into a list of n_colors colors in it

Accepts two color tuples and returns a list of n_colors colors
which form the intermediate colors between lowcolor and highcolor
from linearly interpolating through RGB space
from linearly interpolating through RGB space. If colortype is 'rgb'
the function will return a list of colors in the same form.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this a typo? i thought tuple returned a list (well, a tuple) of colors?

Copy link
Contributor Author

@Kully Kully Nov 15, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can see how there's confusion.

If colortype='tuple', lowcolor and highcolor must be what I have called tuple(-like) colors i.e. tuples of the form (a,b,c) where a, b, c are between 0 and 1, where (0,0,0) == rgb(0,0,0), etc.

If the input islowcolor=(0,0,0), highcolor=(1,1,1), n_colors=3 then the output is:

[(0, 0, 0), (0.5, 0.5, 0.5), (1, 1, 1)]

If colortype='rgb', then the list above will contain the rgb equivalent colors in a list

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also this function will error if you input tuple-colors and colortype is set to rgb. Another thing to fix.

"""
if colortype == 'rgb':
# convert to tuple
lowcolor = unlabel_rgb(lowcolor)
highcolor = unlabel_rgb(highcolor)

diff_0 = float(highcolor[0] - lowcolor[0])
incr_0 = diff_0/(n_colors - 1)
diff_1 = float(highcolor[1] - lowcolor[1])
Expand All @@ -520,6 +542,10 @@ def n_colors(lowcolor, highcolor, n_colors):
lowcolor[2] + (index * incr_2))
color_tuples.append(new_tuple)

if colortype == 'rgb':
# convert back to rgb
color_tuples = color_parser(color_tuples, label_rgb)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this say color_rgb rather than color_tuples?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should, but I kept it like that so that color_tuples is a common variable in both paths the function can take. Shall I change to list_of_colors for an apt name?


return color_tuples


Expand Down
1 change: 1 addition & 0 deletions plotly/figure_factory/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from plotly.figure_factory._2d_density import create_2d_density
from plotly.figure_factory._annotated_heatmap import create_annotated_heatmap
from plotly.figure_factory._bullet import create_bullet
from plotly.figure_factory._candlestick import create_candlestick
from plotly.figure_factory._dendrogram import create_dendrogram
from plotly.figure_factory._distplot import create_distplot
Expand Down
257 changes: 257 additions & 0 deletions plotly/figure_factory/_bullet.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
from __future__ import absolute_import

import math

from plotly import colors, exceptions, optional_imports
from plotly.figure_factory import utils

import plotly
import plotly.graph_objs as go

pd = optional_imports.get_module('pandas')

VALID_KEYS = ['title', 'subtitle', 'ranges', 'measures', 'markers']


def _bullet(df, as_rows, marker_size, marker_symbol, range_colors,
measure_colors, subplot_spacing):
num_of_lanes = len(df)
num_of_rows = num_of_lanes if as_rows else 1
num_of_cols = 1 if as_rows else num_of_lanes
if not subplot_spacing:
subplot_spacing = 1./num_of_lanes
fig = plotly.tools.make_subplots(
num_of_rows, num_of_cols, print_grid=False,
horizontal_spacing=subplot_spacing,
vertical_spacing=subplot_spacing
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does it make sense to provide horizontal_spacing and vertical_spacing as input arguments to ff.make_bullet instead? that way it's consistent with make_subplots

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it does!

)

# layout
fig['layout'].update(
dict(shapes=[]),
showlegend=False,
barmode='stack',
margin=dict(l=120 if as_rows else 80),
)

if as_rows:
width_axis = 'yaxis'
length_axis = 'xaxis'
else:
width_axis = 'xaxis'
length_axis = 'yaxis'

for key in fig['layout'].keys():
if 'axis' in key:
fig['layout'][key]['showgrid'] = False
fig['layout'][key]['zeroline'] = False
if length_axis in key:
fig['layout'][key]['tickwidth'] = 1
if width_axis in key:
fig['layout'][key]['showticklabels'] = False
fig['layout'][key]['range'] = [0, 1]

# narrow domain if 1 bar
if num_of_lanes <= 1:
fig['layout'][width_axis + '1']['domain'] = [0.4, 0.6]

# marker symbol size
if not marker_size:
if num_of_lanes <= 4:
marker_size = 18
else:
marker_size = 8

if not range_colors:
range_colors = ['rgb(200, 200, 200)', 'rgb(245, 245, 245)']
if not measure_colors:
measure_colors = ['rgb(31, 119, 180)', 'rgb(176, 196, 221)']

for row in range(num_of_lanes):
# ranges bars
for idx in range(len(df.iloc[row]['ranges'])):
inter_colors = colors.n_colors(
range_colors[0], range_colors[1],
len(df.iloc[row]['ranges']), 'rgb'
)
x = [sorted(df.iloc[row]['ranges'])[-1 - idx]] if as_rows else [0]
y = [0] if as_rows else [sorted(df.iloc[row]['ranges'])[-1 - idx]]
bar = go.Bar(
x=x,
y=y,
marker=dict(
color=inter_colors[-1 - idx]
),
name='ranges',
hoverinfo='x' if as_rows else 'y',
orientation='h' if as_rows else 'v',
width=2,
base=0,
xaxis='x{}'.format(row + 1),
yaxis='y{}'.format(row + 1)
)
fig['data'].append(bar)

# measures bars
for idx in range(len(df.iloc[row]['measures'])):
inter_colors = colors.n_colors(
measure_colors[0], measure_colors[1],
len(df.iloc[row]['measures']), 'rgb'
)
x = ([sorted(df.iloc[row]['measures'])[-1 - idx]] if as_rows
else [0.5])
y = ([0.5] if as_rows
else [sorted(df.iloc[row]['measures'])[-1 - idx]])
bar = go.Bar(
x=x,
y=y,
marker=dict(
color=inter_colors[-1 - idx]
),
name='measures',
hoverinfo='x' if as_rows else 'y',
orientation='h' if as_rows else 'v',
width=0.4,
base=0,
xaxis='x{}'.format(row + 1),
yaxis='y{}'.format(row + 1)
)
fig['data'].append(bar)

# markers
x = df.iloc[row]['markers'] if as_rows else [0.5]
y = [0.5] if as_rows else df.iloc[row]['markers']
markers = go.Scatter(
x=x,
y=y,
marker=dict(
color='rgb(0, 0, 0)',
symbol=marker_symbol,
size=marker_size
),
name='markers',
hoverinfo='x' if as_rows else 'y',
xaxis='x{}'.format(row + 1),
yaxis='y{}'.format(row + 1)
)
fig['data'].append(markers)

# labels
title = df.iloc[row]['title']
if 'subtitle' in df:
subtitle = '<br>{}'.format(df.iloc[row]['subtitle'])
else:
subtitle = ''
label = '<b>{}</b>'.format(title) + subtitle
annot = utils.annotation_dict_for_label(
label,
(num_of_lanes - row if as_rows else row + 1),
num_of_lanes, subplot_spacing,
'row' if as_rows else 'col',
True if as_rows else False,
False
)
fig['layout']['annotations'].append(annot)

return fig


def create_bullet(df, as_rows=True, marker_size=16,
marker_symbol='diamond-tall', range_colors=None,
measure_colors=None, subplot_spacing=None,
title='Bullet Chart', height=600, width=1000):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we include these types of properties in the other figure factories? I personally would prefer a more generic solution like requiring users to modify the layout directly or passing in **layout_options or layout=None

fig = create_bullet(...)
fig['layout'].update({
    'title': 'My Chart',
    'height': 600,
    'width': 1000,
    'annotations': [{...}]
})

or (**layout_options)

fig = create_bullet(..., title='my chart', width=500, annotations=[{...}])

or (layout_options)

fig = create_bullet(..., layout_options={'title': 'my chart', 'width': 500, 'annotations': [{...}]})

"""
Returns figure for bullet chart.
:param (pd.DataFrame | list) df: either a JSON list of dicts or a pandas
DataFrame. All keys must be one of 'title', 'subtitle', 'ranges',
'measures', and 'markers'.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems unlike the rest of our figure factories, but I might be mistaken. It seems odd to require users to reformat their dataframes. Would it be better if it was something like:

create_bullet(df, title='column a', measures='column b', markers='column c')

?

In other words, let users pass in whatever dataframe they want but then have them specify the column mapping

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Haha, if there are they are probably ones I wrote. This makes way more sense, great suggestion.

:param (bool) as_rows: if True, the bars are placed horizontally as rows.
If False, the bars are placed vertically in the chart.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would orientation='v' | 'h' be more consistent with the rest of plotly here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes again. 👍

:param (int) marker_size: sets the size of the markers in the chart.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is outdated

:param (str | int) marker_symbol: the symbol of the markers in the chart.
Default='diamond-tall'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similarly here, can we make this more generic with something like scatter_options=None that get recursively merged into the trace:

fig = create_bullet(df, scatter_options={'marker': {'size': 12, 'symbol': 'diamond-tall'}, 'name': 'Bullet'})

By making it more abstract, we allow the user to pass in whatever they want and we don't have to keep adding new attributes when they are requestd (e.g. what if the user wants a line around their markers, or wants to change the name of the trace, or wants to change the color of the marker - instead of having marker_line_color=None, name=None, marker_color=None, we could just let the user pass in an object that we recursively merge onto the appropriate trace)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

by recursively merge i mean deep merge

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this too

:param (list) range_colors: a list of two colors between which all
the rectangles for the range are drawn. These rectangles are meant to
be qualitative indicators against which the marker and measure bars
are compared.
Default=['rgb(198, 198, 198)', 'rgb(248, 248, 248)']
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see rgb(248, 248, 248) anywhere else in the code.

Would it make sense to supply this value directly in the function instead of None? i.e.

def create_bullet(data, markers=None, measures=None, ranges=None,
                   subtitles=None, titles=None, orientation='h',
                   range_colors=('rgb(198, 198, 198)', 'rgb(248, 248, 248)',),
                   measure_colors=('rgb(31, 119, 280)', 'rgb(176, 196, 221)', ),
                   horizontal_spacing=None, vertical_spacing=None,
                   scatter_options={}, **layout_options):

(note that I'm using a tuple instead of a list because mutable default arguments aren't safe: http://docs.python-guide.org/en/latest/writing/gotchas/)

:param (list) measure_colors: a list of two colors which is used to color
the thin quantitative bars in the bullet chart.
Default=['rgb(31, 119, 180)', 'rgb(176, 196, 221)']
:param (float) subplot_spacing: set the distance between each bar chart.
If not specified an automatic spacing is assigned based on the number
of bars to be plotted.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What units are these in and what's the range of these numbers?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 to 1.

:param (str) title: title of the bullet chart.
:param (float) height: height of the chart.
:param (float) width width of the chart.
"""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we include a couple of simple examples in here?

# validate df
if not pd:
raise exceptions.ImportError(
"'pandas' must be imported for this figure_factory."
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"imported" - it's really installed, not imported, right? The user doesn't need to do a import pandas before they call this function do they?
If so, could we correct this typo in any other place that it might appear

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's correct, it just needs to be installed on their system

)

if isinstance(df, list):
if not all(isinstance(item, dict) for item in df):
raise exceptions.PlotlyError(
'If your data is a list, all entries must be dictionaries.'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's confusing to lead with a "If your data is a list" - the user might wonder if there data is indeed a list or not. What about something like "Every entry of the data argument (a list) must be a dictionary."

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about this:

'Every entry of the data argument (a list, tuple, etc) must be a dictionary.'

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 very nice

)
df = pd.DataFrame(df)

elif not isinstance(df, pd.DataFrame):
raise exceptions.PlotlyError(
'You must input a pandas DataFrame or a list of dictionaries.'
)

# check for valid keys
if any(key not in VALID_KEYS for key in df.columns):
raise exceptions.PlotlyError(
'Your headers/dict keys must be either {}'.format(
utils.list_of_options(VALID_KEYS, 'or')
)
)

# add necessary columns if missing
for key in VALID_KEYS:
if key not in df:
if key in ['title', 'subtitle']:
element = ''
else:
element = []
df[key] = [element for _ in range(len(df))]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⛔️ - We should never mutate the user's data - these functions should have no side-affects


# make sure ranges and measures are not NAN or NONE
for needed_key in ['ranges', 'measures']:
for idx, r in enumerate(df[needed_key]):
try:
r_is_nan = math.isnan(r)
if r_is_nan or r is None:
df[needed_key][idx] = []
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similarly here. if you need to change data, make a copy of their data and then mutate the copy

except TypeError:
pass

# validate custom colors
for colors_list in [range_colors, measure_colors]:
if colors_list:
if len(colors_list) != 2:
raise exceptions.PlotlyError(
"Both 'range_colors' or 'measure_colors' must be a list "
"of two valid colors."
)
colors.validate_colors(colors_list)
colors_list = colors.convert_colors_to_same_type(colors_list,
'rgb')[0]

fig = _bullet(
df, as_rows, marker_size, marker_symbol, range_colors, measure_colors,
subplot_spacing
)

fig['layout'].update(
title=title,
height=height,
width=width,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if this was layout_options then you could just pass in fig['layout'].update(layout_options)

)

return fig
Loading