Commit ef726118 authored by Hueser, Christian (FWCC) - 138593's avatar Hueser, Christian (FWCC) - 138593
Browse files

Resolve "Check color-blind friendly and corporate-design compliant colors for plots"

parent ea789f26
Pipeline #112385 passed with stage
in 1 minute and 17 seconds
# hifis-surveyval
# Framework to help developing analysis scripts for the HIFIS Software survey.
#
# SPDX-FileCopyrightText: 2021 HIFIS Software <support@hifis.net>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""Module offers functionality to create color maps and fill pattern maps."""
from enum import Enum
from typing import Dict, List
from matplotlib import colors, pyplot
class FillPattern(Enum):
"""Represents different fill patterns in Matplotlib plots."""
PATTERN_01 = 1
PATTERN_02 = 2
PATTERN_03 = 3
PATTERN_04 = 4
PATTERN_05 = 5
PATTERN_06 = 6
PATTERN_07 = 7
PATTERN_08 = 8
PATTERN_09 = 9
PATTERN_10 = 10
class DefaultFillPattern:
"""Defines a default fill pattern map to be used in plots."""
# Define mapping from enum FillPattern to string representation of fill
# pattern.
SLASH_FILL_PATTERN: Dict[FillPattern, str] = {
FillPattern.PATTERN_01: '#FFFFFF',
FillPattern.PATTERN_02: '/',
FillPattern.PATTERN_03: '//',
FillPattern.PATTERN_04: '///',
FillPattern.PATTERN_05: '////',
FillPattern.PATTERN_06: '#000000',
}
class CustomColors(Enum):
"""Represents different colors in Matplotlib plots."""
CUSTOM_01 = 1
CUSTOM_02 = 2
CUSTOM_03 = 3
CUSTOM_04 = 4
CUSTOM_05 = 5
CUSTOM_06 = 6
class DefaultColors:
"""Defines default colors to be used in plots."""
# Define mapping from enum Colors to hex representation of colors.
HELMHOLTZ_BLUE: Dict[CustomColors, str] = {
CustomColors.CUSTOM_01: "#99BDD9",
CustomColors.CUSTOM_02: "#669CC6",
CustomColors.CUSTOM_03: "#337BB3",
CustomColors.CUSTOM_04: "#005AA0",
CustomColors.CUSTOM_05: "#004880",
CustomColors.CUSTOM_06: "#002D50",
}
class ColorMapParameters:
"""Creates a color map and a fill pattern map from parameters."""
def __init__(
self,
number_colors_required: int = None,
use_color: bool = True,
use_pattern: bool = False,
color_map_name: str = None,
use_inverted_color_map: bool = False,
brightness_factor: float = 1.0,
custom_color_map: Dict[Enum, str] = DefaultColors.HELMHOLTZ_BLUE,
pattern_map: Dict[Enum, str] = DefaultFillPattern.SLASH_FILL_PATTERN
) -> None:
"""
Initialize parameters and creates color map and fill pattern map.
Args:
number_colors_required (int):
Number to specify how many colors are needed.
use_color (bool):
Flag to specify whether to fill plot objects with colors.
use_pattern (bool):
Flag to specify whether to fill plot objects with patterns.
color_map_name (str):
Name of the color map to use.
use_inverted_color_map (bool):
Flag to specify whether color map need to be inverted.
brightness_factor (float):
Number to specify brightness of colors in color map.
custom_color_map (Dict[CustomColors, str]):
List to specify custom colors in a color map.
pattern_map (Dict[FillPattern, str]):
Dictionary of FillPattern to hatch pattern string mappings.
"""
self._number_colors_required: int = number_colors_required
self._use_color: bool = use_color
self._use_pattern: bool = use_pattern
self._color_map_name: str = color_map_name
self._use_inverted_color_map: bool = use_inverted_color_map
self._brightness_factor: float = brightness_factor
self._custom_color_map: Dict[CustomColors, str] = custom_color_map
self._fill_pattern_map: Dict[FillPattern, str] = pattern_map
self._color_map: colors.Colormap = None
self._pattern_map: List[str] = None
self.create_color_map_and_patterns()
@property
def use_color(self) -> bool:
"""Access for use color flag."""
return self._use_color
@property
def use_pattern(self) -> bool:
"""Access for use pattern flag."""
return self._use_pattern
@property
def color_map(self) -> colors.Colormap:
"""Access color map property."""
return self._color_map
@property
def pattern_map(self) -> List[str]:
"""Access fill pattern map property."""
return self._pattern_map
def create_color_map_and_patterns(self) -> None:
"""Create a color map and fill pattern map from parameters."""
# Custom color map is given
if self._color_map_name is None and self._custom_color_map is not None:
color_count = len(self._custom_color_map)
# Set number of colors required to number of all colors in custom
# map if not number is given.
if self._number_colors_required is None:
self._number_colors_required = color_count
# Raise exception if there are not enough colors in the
# selected color map.<
if self._number_colors_required > color_count:
raise NotImplementedError(
f"Attempt to plot a chart "
f"with more then {color_count} columns."
f"Color palette has not enough colors for all of them."
)
# Reduce the colormap to have only as much colors as there
# are columns so each columns color index matches the column
# index.
color_map_list = []
for index in range(1, self._number_colors_required + 1):
# Get color specified in custom color map at respective
# position
color = self._custom_color_map[CustomColors(index)]
# Use color specified in pattern map if given
if not self._use_color and self._use_pattern:
if FillPattern(index) in self._fill_pattern_map and \
'#' in self._fill_pattern_map[FillPattern(index)]:
# Use bar color specified in pattern map
color = self._fill_pattern_map[FillPattern(index)]
else:
# Use bar color white in case of hatch patterns given
color = '#FFFFFF'
# Use a brightness factor that modifies the color in
# all color components.
color_modified = [component * self._brightness_factor
for component in colors.to_rgb(color)]
color_map_list.append(color_modified)
self._color_map = colors.ListedColormap(color_map_list)
else:
# No custom color map given, hence use pre-defined color map
# Use default color map if no color map name is given
if self._color_map_name is None:
self._color_map_name = "Pastel1"
# Use specified color map or the inverted counterpart
map_name = self._color_map_name \
if not self._use_inverted_color_map \
else self._color_map_name + "_r"
self._color_map = pyplot.get_cmap(map_name)
color_count = len(self._color_map.colors)
# Raise exception if there are not enough colors in the
# selected color map.
if self._number_colors_required > color_count:
raise NotImplementedError(
f"Attempt to plot a chart "
f"with more then {color_count} columns."
f"Color palette has not enough colors for all of them."
)
# Reduce the colormap to have only as much colors as there
# are columns so each columns color index matches the column
# index.
color_map_list = []
for index in range(self._number_colors_required):
# Get color specified in color map at respective position
color = self._color_map.colors[index]
# Use color specified in pattern map if given
if not self._use_color and self._use_pattern:
if FillPattern(index + 1) in self._fill_pattern_map and \
'#' in self._fill_pattern_map[
FillPattern(index + 1)]:
# Use bar color specified in pattern map
color = self._fill_pattern_map[FillPattern(index + 1)]
else:
# Use bar color white in case of hatch patterns given
color = '#FFFFFF'
# Use a brightness factor that modifies the color in
# all color components.
color_modified = [component * self._brightness_factor
for component in color]
color_map_list.append(color_modified)
self._color_map = colors.ListedColormap(color_map_list)
# Generate pattern map
self._pattern_map = None
if self._fill_pattern_map is not None:
# Raise exception if there are not enough patterns in the selected
# pattern map.
if self._number_colors_required > len(self._fill_pattern_map):
raise NotImplementedError(
f"Attempt to plot a chart "
f"with more then {color_count} columns."
f"Color palette has not enough colors for all of them."
)
# Reduce the pattern map to have only as much patterns as there
# are columns so each columns pattern index matches the column
# index.
self._pattern_map = [
self._fill_pattern_map[key]
for key in self._fill_pattern_map.keys()
]
......@@ -33,11 +33,13 @@ import os
from inspect import FrameInfo, getmodulename, stack
from pathlib import Path
from textwrap import wrap
from typing import List, Optional, Tuple
from typing import Dict, List, Optional, Tuple
from matplotlib import colors, pyplot, rcParams
from pandas import DataFrame
from hifis_surveyval.plotting.matplotlib_color_map_parameters import \
ColorMapParameters, DefaultColors, FillPattern, DefaultFillPattern
from hifis_surveyval.plotting.plotter import Plotter
from hifis_surveyval.plotting.supported_output_format import \
SupportedOutputFormat
......@@ -232,41 +234,74 @@ class MatplotlibPlotter(Plotter):
The figure is auto-sized if the figure size is not given.)
plot_style_name (str):
This indicates which plot style to use.
bar_color_map_parameters (ColorMapParameters):
Object to initialize the parameters to create the color map
for bars.
label_color_map_parameters (ColorMapParameters):
Object to initialize the parameters to create the color map
for value labels of bars.
"""
MatplotlibPlotter._set_custom_plot_style(
kwargs.get("plot_style_name", ""))
rcParams.update({"figure.autolayout": True})
# Color map Generation:
# 1. Pick a suitable predefined colormap. Chose one with light colors
# so the value labels can stand out by darkening them.
base_color_map = pyplot.get_cmap("Pastel1")
color_count = len(base_color_map.colors)
if len(data_frame.columns) > color_count:
raise NotImplementedError(
f"Attempt to plot a bar chart "
f"with more then {color_count} columns per row."
f"Color palette has not enough colors for all of them."
f"(is bar chart a fitting diagram type here?)"
f"(Would transposing the data frame help?)"
bar_color_map_parameters: ColorMapParameters = \
kwargs.get("bar_color_map_parameters", None)
# Define default color scheme if not explicitly given
if bar_color_map_parameters is None:
bar_color_map_parameters = ColorMapParameters(
number_colors_required=len(data_frame.columns),
brightness_factor=1.0,
use_color=True,
use_pattern=False,
custom_color_map=DefaultColors.HELMHOLTZ_BLUE,
pattern_map=DefaultFillPattern.SLASH_FILL_PATTERN
)
# 2. Reduce the colormap to have only as much colors as there are
# columns so each columns color index matches the column index
# (If the colormap were larger one would have to do linear
# interpolation to obtain the proper color.)
color_map = colors.ListedColormap(
[
base_color_map.colors[index]
for index in range(len(data_frame.columns))
]
)
# This new colormap is handed to the graph and used in the value labels
if show_value_labels:
label_color_map_parameters: ColorMapParameters = \
kwargs.get("label_color_map_parameters", None)
# Define default color scheme if not explicitly given
if label_color_map_parameters is None:
label_color_map_parameters = ColorMapParameters(
number_colors_required=len(data_frame.columns),
brightness_factor=0.5,
use_color=True,
use_pattern=False,
custom_color_map=DefaultColors.HELMHOLTZ_BLUE,
pattern_map=DefaultFillPattern.SLASH_FILL_PATTERN
)
plot_stacked: bool = kwargs.get("stacked", False)
x_rotation: int = kwargs.get("x_label_rotation", 0)
data_frame.plot(kind="bar", stacked=plot_stacked, cmap=color_map)
pattern_map: Dict[FillPattern, str] = \
bar_color_map_parameters.pattern_map
# Plotting bar with specified bar colors and black bar edge color
bar_plot = data_frame.plot(
kind="bar",
stacked=plot_stacked,
colormap=bar_color_map_parameters.color_map,
fill=True,
edgecolor="#000000")
# set hatch if hatches are given
if bar_color_map_parameters.use_pattern and pattern_map is not None:
repeated_pattern_index = \
[
index
for index in range(len(data_frame.columns))
for i in range(int(
len(bar_plot.axes.patches) / len(data_frame.columns)
))
]
for index, bar in zip(repeated_pattern_index,
bar_plot.axes.patches):
# Only use hatch if no hex color value is given in pattern map
if '#' not in pattern_map[index]:
bar.set_hatch(pattern_map[index])
axes = pyplot.gca()
......@@ -293,16 +328,20 @@ class MatplotlibPlotter(Plotter):
axes.get_legend().remove()
if show_value_labels:
# Plot value labels inside a bounding box at the lower end of bars
self._add_bar_chart_value_labels(
data_frame,
color_map,
plot_stacked,
round_value_labels_to_decimals,
data_frame=data_frame,
color_map=bar_color_map_parameters.color_map,
label_color_map=label_color_map_parameters.color_map,
plot_stacked=plot_stacked,
round_value_labels_to_decimals=round_value_labels_to_decimals,
show_label_box=True
)
# Set custom figure size or auto-size the figure if figure size is not
# given.
default_width = len(data_frame.index) * 0.25
default_width = \
len(data_frame.index) * 1.25 + len(data_frame.columns) * 1.25
default_height = 5
MatplotlibPlotter._customize_figure_size(
kwargs.get("figure_size", (default_width, default_height)))
......@@ -314,8 +353,10 @@ class MatplotlibPlotter(Plotter):
cls,
data_frame: DataFrame,
color_map: colors.Colormap,
label_color_map: colors.Colormap,
plot_stacked: bool,
round_value_labels_to_decimals: int = 0,
show_label_box: bool = False
) -> None:
"""
Add value labels to a bar chart.
......@@ -327,10 +368,14 @@ class MatplotlibPlotter(Plotter):
The data frame providing the data for the chart.
color_map (Colormap):
The color map used by the bar chart.
label_color_map (Colormap):
The color map used by the bar chart for value labels.
plot_stacked (bool):
Whether the chart is a stacked bar chart or not.
round_value_labels_to_decimals (int):
Round label values to the number of decimals. (Default: 0)
show_label_box (bool):
Show bounding boxes around value labels of bars.
"""
default_font_size = rcParams["font.size"]
axes = pyplot.gca()
......@@ -339,6 +384,11 @@ class MatplotlibPlotter(Plotter):
column_count: int = len(data_frame.columns)
row_count: int = len(data_frame.index)
bbox_dict = None
if show_label_box:
bbox_dict = dict(boxstyle="Round4,pad=0.2",
fc="white", ec="gray", lw=1)
if plot_stacked:
sums = data_frame.sum(axis=1)
minimum_value = sums.min()
......@@ -366,8 +416,7 @@ class MatplotlibPlotter(Plotter):
continue
color = color_map.colors[column]
# Darken the color by setting each component to 50%
color_dark = [0.5 * component for component in color]
label_color = label_color_map.colors[column]
bar_center_y = row_sum + value / 2
......@@ -399,10 +448,12 @@ class MatplotlibPlotter(Plotter):
text_x = row + text_x_offset
# Plot the indicator line
# (at the lowest zorder, not to interfere with hatches)
pyplot.plot(
[text_x + line_x_overhang, row],
[bar_center_y, bar_center_y],
color=color,
zorder=0
)
# Round values to the number of decimals given in parameter
......@@ -421,8 +472,9 @@ class MatplotlibPlotter(Plotter):
value_rounded,
ha="center",
va="center",
color=color_dark,
color=label_color,
size=default_font_size if value < 100 else "smaller",
bbox=bbox_dict
)
# for next row iteration:
row_sum += value
......@@ -456,10 +508,7 @@ class MatplotlibPlotter(Plotter):
bar_offset = bar_width / 2
for row in range(row_count):
for column in range(column_count):
color = color_map.colors[column]
# Darken the color by setting each component to 50%
color = [0.5 * component for component in color]
label_color = label_color_map.colors[column]
# Within each bar area the bar for each column starts at:
column_offset = bar_width * column
......@@ -489,8 +538,9 @@ class MatplotlibPlotter(Plotter):
value_rounded,
ha="center",
va="center",
color=color,
color=label_color,
size=default_font_size if value < 100 else "smaller",
bbox=bbox_dict
)
def plot_box_chart(
......@@ -532,6 +582,8 @@ class MatplotlibPlotter(Plotter):
The figure is auto-sized if the figure size is not given.)
plot_style_name (str):
This indicates which plot style to use.
box_face_color (str):
Name of face color of boxes. (Default: "wheat")
"""
MatplotlibPlotter._set_custom_plot_style(
kwargs.get("plot_style_name", ""))
......@@ -588,7 +640,7 @@ class MatplotlibPlotter(Plotter):
# Fill the box background so the boxes overlay the grid lines
for patch in plot["boxes"]:
patch.set_facecolor("wheat")
patch.set_facecolor(kwargs.get("box_face_color", "wheat"))
data_frame_counter += 1
axes.set_xticklabels(
......@@ -645,17 +697,30 @@ class MatplotlibPlotter(Plotter):
The figure is auto-sized if the figure size is not given.)
plot_style_name (str):
This indicates which plot style to use.
color_map_name (str):
Name determines the color map selected to fill cells.
add_value_label_box (bool):
Flag specifies whether to add value label boxes.
"""
MatplotlibPlotter._set_custom_plot_style(
kwargs.get("plot_style_name", ""))
rcParams.update({"figure.autolayout": True})
color_map_name = "Blues_r" if invert_colors else "Blues"
color_map_name = kwargs.get("color_map_name", "Blues")
if invert_colors:
color_map_name = color_map_name + "_r"
color_map = pyplot.get_cmap(color_map_name)
column_count: int = len(data_frame.columns)
row_count: int = len(data_frame.index)
add_value_label_box: bool = kwargs.get("add_value_label_box", True)
bbox_dict = None
if add_value_label_box:
bbox_dict = dict(boxstyle="Round4,pad=0.2",
fc="white", ec="gray", lw=1)
x_tick_labels = [
"\n".join(wrap(label, 20)) for label in data_frame.columns.values
]
......@@ -690,12 +755,11 @@ class MatplotlibPlotter(Plotter):
else (value > threshold)
)
axes.text(
j,
i,
value,
ha="center",
va="center",
color="white" if switch_color else "black",
j, i, value,
ha="center", va="center",
color="white"
if switch_color and not add_value_label_box else "black",
bbox=bbox_dict
)
# Set custom figure size or auto-size the figure if figure size is not
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment