Source code for svgHeatmap

"""SVG heatmap chart from a pandas DataFrame."""
# Author(s): Davide.De-Marchi@ec.europa.eu
# Copyright © European Union 2022-2023
# 
# Licensed under the EUPL, Version 1.2 or as soon they will be approved by 
# the European Commission subsequent versions of the EUPL (the "Licence");
# 
# You may not use this work except in compliance with the Licence.
# 
# You may obtain a copy of the Licence at:
# https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12

# Unless required by applicable law or agreed to in writing, software
# distributed under the Licence is distributed on an "AS IS"
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
# express or implied.
# 
# See the Licence for the specific language governing permissions and
# limitations under the Licence.
import pandas as pd
from textwrap import wrap
from ipywidgets import HTML, widgets, Layout
from ipyevents import Event

try:
    from . import colors
    from .vuetify import fontsettings
except:
    import colors
    from vuetify import fontsettings

    
    
# Utility function to word-wrap a long text
def wordwrap(x, maxcharperline=80):
    return '<br>'.join(['<br>'.join(wrap(block, width=maxcharperline)) for block in x.splitlines()])



# Vertical text in SVG
def verticalText(text, x, y, size, color, fontfamily, weight):
    return '''
<text x="0" y="0" font-size="%f" fill="%s" font-weight="%d" style="font-family: %s; pointer-events: none;" transform="translate(%f,%f) rotate(-90)">%s</text>
''' % (size, color, weight, fontfamily, x, y, text)


###########################################################################################################################################################################
# Generic heatmap chart from a pandas dataframe containing only numbers (rows names are taken from the index). Returns an Output widget
###########################################################################################################################################################################
[docs]def heatmapChart(df, width=100.0, # width in vw coordinates height=50.0, # height in vh coordinates hTitle=5.0, # height of the Title bar wTitle=15.0, # width of the left space for row titles columnTitleMaxChar=40, # Max number of chars for the column names (longer names are split) textRows='Row', textColumns='Column', textValues='Value', title='Heatmap', fontsize=1.0, colorlist=['rgb(247,251,255)', 'rgb(222,235,247)', 'rgb(198,219,239)', 'rgb(158,202,225)', 'rgb(107,174,214)', 'rgb(66,146,198)', 'rgb(33,113,181)', 'rgb(8,81,156)', 'rgb(8,48,107)'], textcolor='black', textweight=400, backcolor= 'white', highlitecolor='#426bb4', highliteback='#dddddd', minvalue=0.0, maxvalue=1.0, decimals=2): """ Creation of a heatmap chart given an input DataFrame containing only numbers. The index strings and column names are taken as names for rows and columns. The SVG chart has x coordinates expressed in vw coordinates and the y coordinates expressed invh coordinates. Rows and columns of the chart can be selected and the chart will be sorted on decreasing values (when a column is selected, the rows are sorted, and viceversa) Parameters ---------- df : pandas DataFrame Input DataFrame containing only numbers width : float, optional Width of the chart in vw units (default is 100.0) height : float, optional Height of the chart in vh units (default is 50.0) hTitle : float, optional Height of the Title bar in percentage on the height of the chart (default is 5.0) wTitle : float, optional Width of the left space for row titles in percentage of the width pf the chart (default is 15.0) columnTitleMaxChar : int, optional Max number of chars for the column names (longer names are splitted), (default is 40) textRows : str, optional Title for the rows (default is 'Row') textColumns : str, optional Title for the columns (default is 'Column') textValues : str, optional Text to label the values in the tooltip when the mouse is over a cell of the chart (default is 'Value') title : str, optional Title of the chart (default is 'Heatmap') fontsize : float, optional Size of the standard font to use for values displayed in the X and Y axis in vh coordinates (default is 1.0vh). The chart title and the axis titles will be displayed with sizes proportional to the fontsize parameter (up to two times for the chart title) colorlist : list of colors, optional List of colors to assign to the country polygons (default is the Plotly px.colors.sequential.Blues, see `Plotly sequential color scales <https://plotly.com/python/builtin-colorscales/#builtin-sequential-color-scales>`_ and `Plotly qualitative color sequences <https://plotly.com/python/discrete-color/#color-sequences-in-plotly-express>`_ ) textcolor : str, optional Color to use for rows and columns text (default is 'black') textweight : int, optional Weight of the text (default is 400). The chart title and the axis titles will be displayed with weight equal to textweight+100 backcolor : str, optional Background color (default is 'white') highlitecolor : str, optional Color to use for displaying text of the selected row and colum (default is '#426bb4') highliteback : str, optional Color to use as background of the selected row and column (default is '#dddddd') minvalue : float, optional Minimum value of the DataFrame cells to be used for color assignment (default is 0.0) maxvalue : float, optional Minimum value of the DataFrame cells to be used for color assignment (default is 1.0) decimals : int, optional Number of decimals for the tooltip display of cell values (default is 2) Returns ------- an instance of widgets.Output with the svg chart displayed in it Example ------- Creation of a SVG heatmap chart to display a matrix of random numbers:: from IPython.display import display import pandas as pd import numpy as np from vois import svgHeatmap df = pd.DataFrame(np.random.random((20,50))) display(svgHeatmap.heatmapChart(df, wTitle=7.0, hTitle=70) .. figure:: figures/heatmap.png :scale: 100 % :alt: svgHeatmap example Example of an interactive heatmap chart in SVG """ # Dimension in SVG coordinates svgwidth = 100.0 aspectratio = 0.5*height / width # In landscape mode, usually the height is half the width dimension!!! svgheight = svgwidth * aspectratio if maxvalue <= minvalue: maxvalue = minvalue + 1.0 # original df dfunsorted = df.copy() nrows = df.shape[0] hrow = (svgheight-hTitle)/nrows highlitecolumn = '' highliterow = '' titlefontsize = fontsize*2 #preserve = 'none' preserve = 'xMidYMid meet' # Calculates the SVG string def getSVG(): svg = '<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" viewBox="0 0 %f %f" preserveAspectRatio="%s" width="%fvw" height="%fvh">' % (svgwidth,svgheight, preserve, width,height) svg += ''' <style type="text/css"> @import url('%s'); .cell:hover { cursor: pointer; stroke: #000044; stroke-width:0.1; stroke-dasharray:0.15,0.15; !important; } </style> ''' % (fontsettings.font_url) # Names of row titles in the first column rowTitles = list(df.index) y = hTitle for r in rowTitles: tcolor = textcolor tweight = textweight bcolor = backcolor if r == highliterow: tcolor = highlitecolor tweight = textweight + 100 bcolor = highliteback svg += '<rect fill="%s" width="%f" height="%f" x="0.01" y="%f"></rect>' % (bcolor, wTitle*0.995, hrow*0.95, y+0.2) svg += '<text style="pointer-events: none" text-anchor="end" x="%f" y="%f" font-size="%f" fill="%s" style="font-family: %s;" font-weight="%d">%s</text>' % (wTitle-0.1, y+hrow/2.0+0.5, fontsize, tcolor, fontsettings.font_name, tweight, r) y += hrow # Calculate color interpolator ci = colors.colorInterpolator(colorlist, minvalue, maxvalue) # Cells y = hTitle-0.01 wcolumn = (svgwidth-wTitle) / len(df.columns) w = 0.9 * wcolumn wmod = w if wmod > 3.0: wmod = 3.0 svg += '<rect fill="%s" width="%f" height="%f" x="0.01" y="%f" ></rect>' % (backcolor, wTitle-0.02, hTitle-0.01, 0.01) # Column names textangle = -90.0 # -45.0 x = wTitle y = hTitle-0.01 for c in df.columns: c = str(c) tcolor = textcolor tweight = textweight bcolor = backcolor if c == str(highlitecolumn): tcolor = highlitecolor tweight = textweight + 100 bcolor = highliteback svg += '<rect fill="%s" width="%f" height="%f" x="%f" y="%f" ><title>%s</title></rect>' % (bcolor,w*1.05, hTitle-2.4, x, 2.5, c) if len(c) <= columnTitleMaxChar: svg += verticalText(c, x+w/2+0.2, y, fontsize, tcolor, fontsettings.font_name, tweight) else: text = wordwrap(c,columnTitleMaxChar) lines = text.split('<br>') if len(lines) == 2: dx = wmod/3.5 cx = x + w/2 xx = cx - dx/3 + 0.35 else: dx = wmod/3.5 cx = x + w/2 xx = cx - 2*dx/3 + 0.15 for r in lines: svg += verticalText(r, xx, y, fontsize, tcolor, fontsettings.font_name, tweight) xx += dx x += wcolumn # Cells y = hTitle-0.01 for r in df.index: x = wTitle for c in df.columns: value = df.at[r, c] color = ci.GetColor(value) svg += '<rect stroke-width="0.0" style="fill:%s;" x="%f" width="%f" height="%f" y="%f"></rect>' % (color, x, w*1.2, hrow*1.2, y+0.25) x += wcolumn y += hrow # Cell highlights y = hTitle-0.01 for r in df.index: sr = str(r) ptext = wordwrap(sr) x = wTitle for c in df.columns: sc = str(c) value = df.at[r, c] color = ci.GetColor(value) svalue = '{:.{prec}f}'.format(value, prec=decimals) svg += '<rect class="cell" stroke-width="0.0" style="fill:#ffffff00;" x="%f" width="%f" height="%f" y="%f"><title>%s: %s\n%s: %s\n%s = %s</title></rect>' \ % (x, w*1.1, hrow, y+0.25, textRows, ptext, textColumns, sc, textValues, svalue) x += wcolumn y += hrow # Title texts svg += '<text style="pointer-events: none" text-anchor="end" x="%f" y="%f" font-size="%f" fill="%s" style="font-family: %s;" font-weight="%d">%s</text>' % (wTitle-0.2, hTitle-0.1, fontsize*1.75, textcolor, fontsettings.font_name, textweight+100, textRows) svg += '<text style="pointer-events: none" text-anchor="end" x="%f" y="%f" font-size="%f" fill="%s" style="font-family: %s;" font-weight="%d">%s</text>' % (wTitle-0.2, hTitle/2+fontsize, fontsize*1.75, textcolor, fontsettings.font_name, textweight+100, textColumns) svg += '<text dominant-baseline="middle" text-anchor="middle" x="%f" y="%f" font-size="%f" fill="%s" style="font-family: %s;" font-weight="%d">%s</text>' % (svgwidth/2.0, 1.2, fontsize*2.0, textcolor, fontsettings.font_name, textweight+100, title) # Highlight of the big rect svg += '<rect class="cell" fill="#ffffff00" width="%f" height="%f" x="0.01" y="%f" ><title>Click to deselect rows and columns</title></rect>' % (wTitle-0.02, hTitle-0.01, 0.01) # Highlights of row titles in the first column y = hTitle for r in rowTitles: svg += '<rect class="cell" fill="#ffffff00" width="%f" height="%f" x="0.01" y="%f"><title>%s</title></rect>' % (wTitle*0.995, hrow*0.95, y+0.2, r) y += hrow # Highlight column names x = wTitle y = hTitle-0.01 for c in df.columns: c = str(c) svg += '<rect class="cell" fill="#ffffff00" width="%f" height="%f" x="%f" y="%f" ><title>%s</title></rect>' % (w*1.05, hTitle-2.4, x, 2.5, c) x += wcolumn svg += '</svg>' return svg # Update the chart def updateChart(): output.clear_output(wait=True) with output: display(HTML(getSVG())) #debug = widgets.Output() # Pixels to add to the output Widget in order to not see the scrollbars added_pixels_width = 20 added_pixels_height = 20 # Management of 'click' event on the HEATMAP Output def handle_event_heatmap(event): nonlocal df, highlitecolumn, highliterow x = event['relativeX'] - added_pixels_width/2 - 2 y = event['relativeY'] w = event['boundingRectWidth'] - added_pixels_width h = event['boundingRectHeight'] - added_pixels_height ar = float(h) / float(w) # Calculate dimension and bounding box of chart in pixels if ar > aspectratio: chartw = w charth = w * aspectratio xmin = 0 xmax = chartw ymin = (h - charth)/2 ymax = ymin + charth else: charth = h chartw = h / aspectratio xmin = (w - chartw)/2 xmax = xmin + chartw ymin = 0 ymax = charth # Calculate position in percentage on the chart area in pixels if x >= xmin and x <= xmax and y >= ymin and y <= ymax: xp = 100.0 * (x-xmin) / float(chartw) yp = 100.0 * (y-ymin) / float(charth) - 1.0 wTitlePercent = 100.0*wTitle/svgwidth hTitlePercent = 100.0*hTitle/svgheight nrows = df.shape[0] ncols = df.shape[1] wcol = (100.0 - wTitlePercent)/ncols hrow = (100.0 - hTitlePercent)/nrows #debug.clear_output() #with debug: #print('chartw=',chartw, ' charth=',charth, ' aspectratio=',aspectratio, ' ar=',ar) #print('xmin=',xmin,' ymin=',ymin, ' xmax=',xmax,' ymax=',ymax) #print('wcol=',wcol, ' hrow=',hrow) #print('xp=',xp,' yp=',yp,' wTitlePercent=',wTitlePercent, ' hTitlePercent=',hTitlePercent) # Click on the reset column if xp >= 0 and xp <= wTitlePercent and yp >= 0.0 and yp <= hTitlePercent: #with debug: # print("RESET!") df = dfunsorted.copy() highlitecolumn = '' highliterow = '' updateChart() # Click on a column elif yp >= 2.4 and yp <= hTitlePercent: if yp >= 8.0 and xp >= wTitlePercent: #with debug: # print("COL!", int((xp - wTitlePercent) / wcol)) icol = int((xp - wTitlePercent) / wcol) if icol >= 0 and icol < ncols: highlitecolumn = df.columns[icol] df = df.sort_values(highlitecolumn, ascending=False) # Sort rows by descending values in one column updateChart() # Click on a row elif yp > hTitlePercent: if xp >= 0.0 and xp <= wTitlePercent: #with debug: # print("ROW!", int((yp - hTitlePercent) / hrow)) irow = int((yp - hTitlePercent) / hrow) if irow >= 0 and irow < nrows: highliterow = df.index[irow] df = df.sort_values(highliterow, axis=1, ascending=False) # Sort columns by descending values in one row updateChart() # Create the output widget output = widgets.Output(layout=Layout(width= 'calc(%fvw + %dpx)' % (width,added_pixels_width), height='calc(%fvh + %dpx)' % (height,added_pixels_height), margin='0px 0px 0px 0px')) #border='1px dashed green')) # Create the Event manager dh = Event(source=output, watched_events=['click']) dh.on_dom_event(handle_event_heatmap) updateChart() return output #, debug