# -*- coding: utf-8 -*-
#
# Copyright (c) 2017-2018 Spotify AB
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Module for logic related to chart styles.
"""
from itertools import cycle
import yaml
import bokeh
from bokeh.core.properties import value as bokeh_value
from chartify._core import colors
from chartify._core.options import options
from packaging import version
class BasePalette:
"""Base class for color palettes."""
def __init__(self, chart, palette):
self._chart = chart
self._set_palette_colors(palette)
def _set_palette_colors(self, palette):
try:
# Palette is a string.
# Retreive the appropriate ColorPalette object.
if palette.lower():
palette = colors.color_palettes[palette]
except AttributeError:
pass
palette_colors = getattr(palette, "colors", None)
if palette_colors is not None:
# Palette is a ColorPalette object
self._colors = [color.get_hex_l() for color in palette_colors]
else:
# Palette is a list of color strings
self._colors = [colors.Color(color).get_hex_l() for color in palette]
@classmethod
def _get_palette_class(cls, chart, palette_type="categorical", palette=None, accent_values=None):
if palette_type == "categorical":
if palette is None:
palette_name = options.get_option("style.color_palette_categorical")
palette = colors.color_palettes[palette_name]
return CategoricalPalette(chart, palette)
elif palette_type == "sequential":
if palette is None:
palette_name = options.get_option("style.color_palette_sequential")
palette = colors.color_palettes[palette_name]
return OrdinalPalette(chart, palette)
elif palette_type == "diverging":
if palette is None:
palette_name = options.get_option("style.color_palette_diverging")
palette = colors.color_palettes[palette_name]
return OrdinalPalette(chart, palette)
elif palette_type == "accent":
if palette is None:
palette_name = options.get_option("style.color_palette_accent")
palette = colors.color_palettes[palette_name]
return AccentPalette(chart, palette, accent_values)
else:
raise ValueError(
"""Type must be one of: ('categorical', 'sequential',
'diverging', 'accent')."""
)
def next_colors(self, color_column_values):
"""Return a list of colors associated with each value."""
return [self.next_color(o) for o in color_column_values]
def next_color(self, color_column_value=None):
"""Return the next color from the color palette."""
raise NotImplementedError
class OrderedPaletteMixin:
"""Mixin for palettes that should be applied in order."""
def reset_palette_order(self):
"""Reset the order of the color palette."""
self._color_cycle = cycle(self._colors)
def next_color(self, color_column_value=None):
"""Return the next color from the color palette."""
return next(self._color_cycle)
class CategoricalPalette(OrderedPaletteMixin, BasePalette):
"""Categorical palettes are those that have no designated order."""
def __init__(self, chart, palette):
super(CategoricalPalette, self).__init__(chart, palette)
self._color_cycle = cycle(self._colors)
class OrdinalPalette(OrderedPaletteMixin, BasePalette):
"""Ordinal palettes have an order associated with the color dimension."""
def __init__(self, chart, palette):
super(OrdinalPalette, self).__init__(chart, palette)
self._color_cycle = cycle(self._colors)
def next_colors(self, color_column_values):
"""Return a list of colors associated with each value."""
palette_colors = self._colors
if len(color_column_values) > len(self._colors):
palette = colors.ColorPalette.from_hex_list(colors=self._colors).expand_palette(len(color_column_values))
palette_colors = [color.get_hex_l() for color in palette.colors]
return bokeh.palettes.linear_palette(palette_colors, len(color_column_values))
class AccentPalette(BasePalette):
"""Accent Palette.
Accent palette assigns specific colors to specific values
within the color dimension.
The default color is used for values that are unassigned."""
def __init__(self, chart, palette, accent_values=None):
super(AccentPalette, self).__init__(chart, palette)
self._accent_color_map = None
self.set_accent_values(accent_values)
self.set_default_color(options.get_option("style.color_palette_accent_default_color"))
def set_accent_values(self, accent_values):
"""Set values that should be accented.
Args:
- accent_values (list or dict): List of values that
should be accented or dictionary of 'value': 'color' pairs.
"""
if isinstance(accent_values, dict):
self._accent_color_map = {value: colors.Color(color).get_hex_l() for value, color in accent_values.items()}
else:
self._accent_color_map = dict(list(zip(accent_values, cycle(self._colors))))
return self._chart
def next_color(self, color_column_value=None):
"""Return the color for the given values.
Args:
color_column_value: TODO
"""
return self._accent_color_map.get(color_column_value, self._default_color)
def set_default_color(self, color):
"""
Set default color of values in the 'color_column'
that are not accented."""
color = colors.Color(color).get_hex_l()
self._default_color = color
[docs]class Style:
"""
Contains attributes and methods for modifying the aesthetic
style of the chart.
"""
def __init__(self, chart, layout):
self._chart = chart
self.color_palette = BasePalette._get_palette_class(self._chart)
self._layout = layout
self._set_width_and_height(layout)
self.settings = {
"legend": {
"figure.legend.orientation": "horizontal",
"figure.legend.location": "top_left",
"figure.legend.label_text_font": "helvetica",
},
"chart": {
"figure.background_fill_color": "white",
"figure.xgrid.grid_line_color": None,
"figure.ygrid.grid_line_color": None,
"figure.border_fill_color": "white",
"figure.min_border_left": 60,
"figure.min_border_right": 60,
"figure.min_border_top": 40,
"figure.min_border_bottom": 60,
"figure.xaxis.axis_line_width": 1,
"figure.yaxis.axis_line_width": 1,
"figure.yaxis.axis_line_color": "#C0C0C0",
"figure.xaxis.axis_line_color": "#C0C0C0",
"figure.yaxis.axis_label_text_color": "#666666",
"figure.xaxis.axis_label_text_color": "#666666",
"figure.xaxis.major_tick_line_color": "#C0C0C0",
"figure.xaxis.minor_tick_line_color": "#C0C0C0",
"figure.yaxis.major_tick_line_color": "#C0C0C0",
"figure.yaxis.minor_tick_line_color": "#C0C0C0",
"figure.xaxis.major_label_text_color": "#898989",
"figure.yaxis.major_label_text_color": "#898989",
"figure.outline_line_alpha": 1,
"figure.outline_line_color": "white",
"figure.xaxis.axis_label_text_font": "helvetica",
"figure.yaxis.axis_label_text_font": "helvetica",
"figure.yaxis.major_label_text_font": "helvetica",
"figure.xaxis.major_label_text_font": "helvetica",
"figure.yaxis.axis_label_text_font_style": "bold",
"figure.xaxis.axis_label_text_font_style": "bold",
"figure.yaxis.axis_label_text_font_size": "11pt",
"figure.xaxis.axis_label_text_font_size": "11pt",
"figure.yaxis.major_label_text_font_size": "10pt",
"figure.xaxis.major_label_text_font_size": "10pt",
"figure.title.text_font": "helvetica",
"figure.title.text_color": "#333333",
"figure.title.text_font_size": "18pt",
"figure.xaxis.minor_tick_out": 1,
"figure.yaxis.minor_tick_out": 1,
"figure.xaxis.major_tick_line_width": 1,
"figure.yaxis.major_tick_line_width": 1,
"figure.xaxis.major_tick_out": 4,
"figure.yaxis.major_tick_out": 4,
"figure.xaxis.major_tick_in": 0,
"figure.yaxis.major_tick_in": 0,
},
"categorical_xaxis": {
# Used for grouped categorical axes
"figure.xaxis.separator_line_alpha": 0,
"figure.xaxis.subgroup_text_font": "helvetica",
"figure.xaxis.group_text_font": "helvetica",
"figure.xaxis.subgroup_text_font_size": "11pt",
"figure.xaxis.group_text_font_size": "11pt",
"figure.x_range.factor_padding": 0.25,
},
"categorical_yaxis": {
# Used for grouped categorical axes
"figure.yaxis.separator_line_alpha": 0,
"figure.yaxis.subgroup_text_font": "helvetica",
"figure.yaxis.group_text_font": "helvetica",
"figure.y_range.factor_padding": 0.25,
"figure.yaxis.subgroup_text_font_size": "11pt",
"figure.yaxis.group_text_font_size": "11pt",
},
"categorical_xyaxis": {
# Used for grouped categorical axes
"figure.yaxis.separator_line_alpha": 0,
"figure.yaxis.subgroup_text_font": "helvetica",
"figure.yaxis.group_text_font": "helvetica",
"figure.yaxis.subgroup_text_font_size": "11pt",
"figure.yaxis.group_text_font_size": "11pt",
# Used for grouped categorical axes
"figure.xaxis.separator_line_alpha": 0,
"figure.xaxis.subgroup_text_font": "helvetica",
"figure.xaxis.group_text_font": "helvetica",
"figure.xaxis.subgroup_text_font_size": "11pt",
"figure.xaxis.group_text_font_size": "11pt",
},
"subtitle": {
"subtitle_align": "left",
"subtitle_text_color": "#666666",
"subtitle_location": "above",
"subtitle_text_size": "12pt",
"subtitle_text_font": "helvetica",
},
"text_callout_and_plot": {
"font": self._font_value("helvetica"),
},
"interval_plot": {
"space_between_bars": 0.25,
"margin": 0.05,
"bar_width": 0.9,
"space_between_categories": 1.15,
# Note each stem is drawn twice
"interval_end_stem_size": 0.1 / 2,
"interval_midpoint_stem_size": 0.03 / 2,
},
"line_plot": {
"line_cap": "round",
"line_join": "round",
"line_width": 4,
"line_dash": "solid",
},
"second_y_axis": {
"figure.yaxis[1].axis_label_text_color": "#666666",
"figure.yaxis[1].axis_line_color": "#C0C0C0",
"figure.yaxis[1].axis_line_width": 1,
"figure.yaxis[1].major_tick_line_color": "#C0C0C0",
"figure.yaxis[1].minor_tick_line_color": "#C0C0C0",
"figure.yaxis[1].major_label_text_color": "#898989",
"figure.yaxis[1].axis_label_text_font": "helvetica",
"figure.yaxis[1].major_label_text_font": "helvetica",
"figure.yaxis[1].axis_label_text_font_style": "bold",
"figure.yaxis[1].axis_label_text_font_size": "11pt",
"figure.yaxis[1].major_label_text_font_size": "10pt",
"figure.yaxis[1].minor_tick_out": 1,
"figure.yaxis[1].major_tick_line_width": 1,
"figure.yaxis[1].major_tick_out": 4,
"figure.yaxis[1].major_tick_in": 0,
},
}
config_filename = options.get_option("config.style_settings")
try:
self._settings_from_yaml(config_filename, apply_chart_settings=False)
except FileNotFoundError:
pass
@staticmethod
def _font_value(text_font):
# https://github.com/bokeh/bokeh/issues/11044
if version.parse(bokeh.__version__) < version.parse("2.3"):
return text_font
else: # >= 2.3
return bokeh_value(text_font)
def _set_width_and_height(self, layout="slide_100%"):
"""Set plot width and height based on the layout"""
self.plot_width = 960
self.plot_height = 540
height_multiplier, width_multiplier = 1.0, 1.0
if layout == "slide_75%":
height_multiplier = 1.0 * 0.8
width_multiplier = 0.75 * 0.8
elif layout == "slide_50%":
height_multiplier = 1.0
width_multiplier = 0.5
elif layout == "slide_25%":
height_multiplier = 0.5
width_multiplier = 0.5
self.plot_height = int(self.plot_height * height_multiplier)
self.plot_width = int(self.plot_width * width_multiplier)
[docs] def set_color_palette(self, palette_type, palette=None, accent_values=None):
"""
Args:
palette_type:
- 'categorical': Use when the color dimension
has no meaningful order.
- 'sequential': Use when the color dimension
has a sequential order.
- 'diverging'
- 'accent': Use to assign color to specific
values in the color dimension.
palette (color palette name, ColorPalette object, or list of colors)
See chartify.color_palettes.show() for palette & color names.
Default: 'Spotify Palette'
accent_values (list or dict): List of values that should be
accented or dictionary of 'value': 'color' pairs.
Only applies to 'accent' palette type.
"""
self.color_palette = BasePalette._get_palette_class(
self._chart,
palette_type=palette_type,
palette=palette,
accent_values=accent_values,
)
return self._chart
def _apply_bokeh_settings(self, attributes):
for key, value in attributes.items():
self._apply_bokeh_setting(key, value)
def _apply_bokeh_setting(self, attribute, value, base_obj=None):
"""Recursively apply the settings value to the given settings attribute.
Recursion is necessary because some bokeh objects may
have multiple child objects.
E.g. figures can have more than one x-axis.
"""
# If not a bokeh attribute then we don't need to apply anything.
if "figure" not in attribute and base_obj is None:
return
split_attribute = attribute.split(".")
if base_obj is None:
base_obj = self._chart
if len(split_attribute) == 1:
setattr(base_obj, attribute, value)
else:
for i, attr in enumerate(split_attribute):
# If the attribute contains a list, the slice the list.
list_split = attr.split("[")
list_index = None
if len(list_split) > 1:
list_index = int(list_split[1].replace("]", ""))
attr = list_split[0]
if i < len(split_attribute) - 1:
base_obj = getattr(base_obj, attr)
# Slice the list if list_index is not None
if list_index is not None:
base_obj = base_obj[list_index]
# If the base object is a list, then apply settings to each
# element.
if isinstance(base_obj, (list,)):
for obj in base_obj:
self._apply_bokeh_setting(".".join(split_attribute[i + 1 :]), value, base_obj=obj)
break
else:
setattr(base_obj, attr, value)
def _apply_settings(self, key):
"""Apply the specified bokeh settings"""
setting_values = self.settings[key]
self._apply_bokeh_settings(setting_values)
def _get_settings(self, key):
"""Return the values of the given settings key"""
setting_values = self.settings[key]
return setting_values
def _settings_to_yaml(self, filename):
"""Write the chart settings dict to a yaml file"""
with open(filename, "w") as outfile:
yaml.dump(self.settings, outfile, default_flow_style=False)
def _settings_from_yaml(self, filename, apply_chart_settings=True):
"""Load the chart settings dict from a yaml file"""
yaml_settings = yaml.safe_load(open(filename))
self.settings.update(yaml_settings)
# Apply the settings that have been loaded.
if apply_chart_settings:
self._apply_settings("chart")