Source code for chartify._core.chart

# -*- 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.
"""


"""
from collections import OrderedDict
from functools import wraps
import io
from io import BytesIO
import tempfile
import warnings

import bokeh
from bokeh.io.export import _SVG_SCRIPT, wait_until_render_complete
import bokeh.plotting
from bokeh.embed import file_html

from bokeh.resources import INLINE
from IPython.display import display
from PIL import Image
from PIL.Image import Resampling
from selenium import webdriver
from selenium.webdriver.chrome.options import Options

from chartify._core.style import Style
from chartify._core.axes import BaseAxes, SecondYNumericalAxis, SecondAxis
from chartify._core.plot import BasePlot
from chartify._core.callout import Callout
from chartify._core.options import options


[docs]class Chart: """Class Docstring - Styling (.style) - Plotting (.plot) - Callouts (.callout) - Axes (.axes) - Bokeh figure (.figure) """ def __init__( self, blank_labels=options.get_option("chart.blank_labels"), layout="slide_100%", x_axis_type="linear", y_axis_type="linear", second_y_axis=False, ): """Create a chart instance. Args: blank_labels (bool): When true removes the title, subtitle, axes, and source labels from the chart. Default False. layout (str): Change size & aspect ratio of the chart for fitting into slides. - 'slide_100%' - 'slide_75%' - 'slide_50%' - 'slide_25%' x_axis_type (enum, str): Type of data plotted on the X-axis. - 'linear': - 'log': - 'datetime': Use for datetime formatted data. - 'categorical': - 'density' y_axis_type (enum, str): Type of data plotted on the Y-axis. - 'linear': - 'log': - 'categorical': - 'density' Note: Combination of x_axis_type and y_axis_type will determine the plotting methods available. """ # Validate axis type input valid_x_axis_types = ["linear", "log", "datetime", "categorical", "density"] valid_y_axis_types = ["linear", "log", "categorical", "density"] valid_second_y_axis_types = ["linear", "log"] if x_axis_type not in valid_x_axis_types: raise ValueError("x_axis_type must be one of {options}".format(options=valid_x_axis_types)) if y_axis_type not in valid_y_axis_types: raise ValueError("y_axis_type must be one of {options}".format(options=valid_y_axis_types)) self._second_y_axis_type = None if second_y_axis: self._second_y_axis_type = y_axis_type if self._second_y_axis_type not in valid_second_y_axis_types: raise ValueError( "second_y_axis can only be used when \ y_axis_type is one of {options}".format( options=valid_second_y_axis_types ) ) self._x_axis_type, self._y_axis_type = x_axis_type, y_axis_type self._blank_labels = options._get_value(blank_labels) self.style = Style(self, layout) self.figure = self._initialize_figure(self._x_axis_type, self._y_axis_type) self.style._apply_settings("chart") self.plot = BasePlot._get_plot_class(self._x_axis_type, self._y_axis_type)(self) self.callout = Callout(self) self.axes = BaseAxes._get_axis_class(self._x_axis_type, self._y_axis_type)(self) if self._second_y_axis_type in valid_second_y_axis_types: self.second_axis = SecondAxis() self.second_axis.axes = SecondYNumericalAxis(self) self.second_axis.plot = BasePlot._get_plot_class(self._x_axis_type, self._second_y_axis_type)( self, self.second_axis.axes._y_range_name ) self._source = self._add_source_to_figure() self._subtitle_glyph = self._add_subtitle_to_figure() self.figure.toolbar.logo = None # Remove bokeh logo from toolbar. # Reverse the order of vertical legends. Used with stacked plot types # to ensure that the stack order is consistent with the legend order. self._reverse_vertical_legend = False # Logos disabled for now. # self.logo = Logo(self) # Set default for title title = """ch.set_title('Takeaway')""" if self._blank_labels: title = "" self.set_title(title) def __repr__(self): return """ chartify.Chart(blank_labels={blank_labels}, layout='{layout}', x_axis_type='{x_axis_type}', y_axis_type='{y_axis_type}') """.format( blank_labels=self._blank_labels, layout=self.style._layout, x_axis_type=self._x_axis_type, y_axis_type=self._y_axis_type, ) def _initialize_figure(self, x_axis_type, y_axis_type): range_args = {} if x_axis_type == "categorical": range_args["x_range"] = [] x_axis_type = "auto" if y_axis_type == "categorical": range_args["y_range"] = [] y_axis_type = "auto" if x_axis_type == "density": x_axis_type = "linear" if y_axis_type == "density": y_axis_type = "linear" figure = bokeh.plotting.figure( **range_args, y_axis_type=y_axis_type, x_axis_type=x_axis_type, width=self.style.plot_width, height=self.style.plot_height, tools="save", # toolbar_location='right', active_drag=None ) return figure def _add_subtitle_to_figure(self, subtitle_text=None): """Create the subtitle glyph and add it to the bokeh figure.""" if subtitle_text is None: if self._blank_labels: subtitle_text = "" else: subtitle_text = """ch.set_subtitle('Data Description')""" subtitle_settings = self.style._get_settings("subtitle") _subtitle_glyph = bokeh.models.Title( text=subtitle_text, align=subtitle_settings["subtitle_align"], text_color=subtitle_settings["subtitle_text_color"], text_font_size=subtitle_settings["subtitle_text_size"], text_font=subtitle_settings["subtitle_text_font"], ) self.figure.add_layout(_subtitle_glyph, subtitle_settings["subtitle_location"]) return _subtitle_glyph def _add_source_to_figure(self): """Create the source glyph and add it to the bokeh figure.""" source_text = """ch.set_source_label('Source')""" if self._blank_labels: source_text = "" source_text_color = "#898989" source_font_size = "10px" _source = bokeh.models.Label( x=self.style.plot_width * 0.9, y=0, x_units="screen", y_units="screen", level="overlay", text=source_text, text_color=source_text_color, text_font_size=source_font_size, text_align="right", name="subtitle", ) self.figure.add_layout(_source, "below") return _source @property def data(self): """Return a list of dictionaries of the data that have be plotted on the chart. Note: The format will depend on the types of plots that have been added. """ datasources = self.figure.select({"type": bokeh.models.ColumnDataSource}) # Extract the data attribute from the ColumnDataSource object # and place in a list. datasources_list = list(map(lambda x: x.data, datasources)) return datasources_list @property def source_text(self): """str: Data source of the chart.""" return self._source.text
[docs] def set_source_label(self, source): """Set the chart data source. Args: source (str): Data source. Returns: Current chart object """ self._source.text = source return self
@property def title(self): """str: Title text of the chart.""" return self.figure.title.text
[docs] def set_title(self, title): """Set the chart title. Args: title (str): Title text. Returns: Current chart object """ self.figure.title.text = title return self
@property def subtitle(self): """str: Subtitle text of the chart.""" return self._subtitle_glyph.text
[docs] def set_subtitle(self, subtitle): """Set the chart subtitle. Args: subtitle (str): Subtitle text. Note: Set value to "" to remove subtitle. Returns: Current chart object """ self._subtitle_glyph.text = subtitle return self
@property def legend_location(self): """str: Legend location.""" return self.figure.legend[0].location
[docs] def set_legend_location(self, location, orientation="horizontal"): """Set the legend location. Args: location (str or tuple): Legend location. One of: - Outside of the chart: 'outside_top', 'outside_bottom', 'outside_right' - Within the chart area: 'top_left', 'top_center', 'top_right', 'center_left', 'center', 'center_right', 'bottom_left', 'bottom_center', 'bottom_right' - Coordinates: Tuple(Float, Float) - None: Removes the legend. orientation (str): 'horizontal' or 'vertical' Returns: Current chart object """ def add_outside_legend(legend_location, layout_location): self.figure.legend.location = legend_location if not self.figure.legend: warnings.warn( """ Legend location will not apply. Set the legend after plotting data. """, UserWarning, ) return self new_legend = self.figure.legend[0] new_legend.orientation = orientation self.figure.add_layout(new_legend, layout_location) if location == "outside_top": add_outside_legend("top_left", "above") # Re-render the subtitle so that it appears over the legend. subtitle_index = self.figure.renderers.index(self._subtitle_glyph) self.figure.renderers.pop(subtitle_index) self._subtitle_glyph = self._add_subtitle_to_figure(self._subtitle_glyph.text) elif location == "outside_bottom": add_outside_legend("bottom_center", "below") elif location == "outside_right": add_outside_legend("top_left", "right") elif location is None: self.figure.legend.visible = False else: self.figure.legend.location = location self.figure.legend.orientation = orientation vertical = self.axes._vertical # Reverse the legend order if self._reverse_vertical_legend: if orientation == "vertical" and vertical: self.figure.legend[0].items = list(reversed(self.figure.legend[0].items)) return self
[docs] def show(self, format="html"): """Show the chart. Args: format (str): - 'html': Output chart as HTML. Renders faster and allows for interactivity. Charts saved as HTML in a Jupyter notebooks WILL NOT display on Github. Logos will not display on HTML charts. Recommended when drafting plots. - 'png': Output chart as PNG. Easy to copy+paste into slides. Will render logos. Recommended when the plot is in a finished state. - 'svg': Output as SVG. """ self._set_toolbar_for_format(format) if format == "html": return bokeh.io.show(self.figure) elif format == "png": image = self._figure_to_png() # Need to re-enable this when logos are added back. # image = self.logo._add_logo_to_image(image) return display(image) elif format == "svg": return self._show_svg()
[docs] def save(self, filename, format="html"): """Save the chart. Args: filename (str): Name of output file. format (str): - 'html': Output chart as HTML. Renders faster and allows for interactivity. Charts saved as HTML in a Jupyter notebook WILL NOT display on Github. Logos will not display on HTML charts. Recommended when drafting plots. - 'png': Output chart as PNG. Easy to paste into google slides. Recommended when the plot is in a finished state. Will render logos. - 'svg': Output as SVG. """ self._set_toolbar_for_format(format) if format == "html": bokeh.io.saving.save( self.figure, filename=filename, resources=INLINE, title="Chartify chart.", ) elif format == "png": image = self._figure_to_png() # Need to re-enable this when logos are added back. # image = self.logo._add_logo_to_image(image) image.save(filename) elif format == "svg": image = self._figure_to_svg() self._save_svg(image, filename) print("Saved to {filename}".format(filename=filename)) return self
def _set_toolbar_for_format(self, format): if format == "html": self.figure.toolbar_location = "right" elif format in ("png", "svg"): self.figure.toolbar_location = None elif format is None: # If format is None the chart won't be shown. pass else: raise ValueError("""Invalid format. Valid options are 'html', 'png' or 'svg'.""") def _initialize_webdriver(self): """Initialize headless chrome browser""" options = Options() options.add_argument( "window-size={width},{height}".format(width=self.style.plot_width, height=self.style.plot_height) ) options.add_argument("start-maximized") options.add_argument("disable-infobars") options.add_argument("disable-gpu") options.add_argument("no-sandbox") # Required for use in docker. options.add_argument("--disable-extensions") options.add_argument("--headless") options.add_argument("--hide-scrollbars") driver = webdriver.Chrome(options=options) return driver def _figure_to_png(self): """Convert figure object to PNG Bokeh can only save figure objects as html. To convert to PNG the HTML file is opened in a headless browser. """ driver = self._initialize_webdriver() # Save figure as HTML html = file_html(self.figure, resources=INLINE, title="") fp = tempfile.NamedTemporaryFile("w", prefix="chartify", suffix=".html", encoding="utf-8") fp.write(html) fp.flush() # Open html file in the browser. driver.get("file:///" + fp.name) driver.execute_script("document.body.style.margin = '0px';") png = driver.get_screenshot_as_png() driver.quit() fp.close() # Resize image if necessary. image = Image.open(BytesIO(png)) target_dimensions = (self.style.plot_width, self.style.plot_height) if image.size != target_dimensions: image = image.resize(target_dimensions, resample=Resampling.LANCZOS) return image def _set_svg_backend_decorator(f): """Sets the chart backend to svg and resets after the function has run.""" @wraps(f) def wrapper(self, *args, **kwargs): old_backend = self.figure.output_backend self.figure.output_backend = "svg" return f(self, *args, **kwargs) self.figure.output_backend = old_backend return wrapper @_set_svg_backend_decorator def _show_svg(self): """Show the chart figure with an svg output backend.""" return bokeh.io.show(self.figure) @_set_svg_backend_decorator def _figure_to_svg(self): """ Convert the figure to an svg so that it can be saved to a file. https://github.com/bokeh/bokeh/blob/master/bokeh/io/export.py """ driver = self._initialize_webdriver() html = file_html(self.figure, resources=INLINE, title="") fp = tempfile.NamedTemporaryFile("w", prefix="chartify", suffix=".html", encoding="utf-8") fp.write(html) fp.flush() driver.get("file:///" + fp.name) wait_until_render_complete(driver, 5) svgs = driver.execute_script(_SVG_SCRIPT) fp.close() driver.quit() return svgs[0] def _save_svg(self, svg, filename): """Write the svg to a file""" with io.open(filename, mode="w", encoding="utf-8") as f: f.write(svg)
class Logo: def __init__(self, chart): self._chart = chart self._logo_image = None self._path = options.get_option("config.logos_path") self._logo_file_mapping = {} self._logo_file_mapping = OrderedDict(sorted(list(self._logo_file_mapping.items()), key=lambda t: t[0])) def _add_logo_to_image(self, image): """If the logo is set then add it to the chart image.""" if self._logo_image is None: return image x_dim = image.getbbox()[2] width = self._logo_image.getbbox()[2] padding = 10 coords = (x_dim - width - padding, 0 + padding) image.paste(self._logo_image, coords, self._logo_image) return image def _resize_logo(self, logo_image): logo_width, logo_height = logo_image.size # TODO smart scaling of logos target_height = int(self._chart.style.plot_height * 0.1) if logo_width == logo_height: logo_image = logo_image.resize((target_height, target_height), resample=Image.LANCZOS) else: logo_width_to_height = logo_width * 1.0 / logo_height logo_image = logo_image.resize( (int(logo_width_to_height * target_height), target_height), resample=Image.LANCZOS, ) return logo_image def show_logo_options(self): for name, filename in self._logo_file_mapping.items(): logo_image = Image.open(self._path + filename) display(name) display(self._resize_logo(logo_image)) def set_logo(self, logo=None): """Add logo to the chart. Notes: Use .show_logo_options() to see available logos. Logo will only appear when .show('png') is used. """ try: filename = self._logo_file_mapping[logo] except KeyError: raise KeyError( "Must supply a valid logo name: {valid_options}".format( valid_options=list(self._logo_file_mapping.keys()) ) ) logo_image = Image.open(self._path + filename) logo_image = self._resize_logo(logo_image) self._logo_image = logo_image