diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index a072bae1..e44dbd4a 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -181,6 +181,8 @@ jobs:
if: ${{ steps.gofull.outcome != 'success' && steps.godash.outcome != 'success' && steps.goapi.outcome == 'success' }}
run: |
python covid_data_api.py
+ python matplotlib_svg_x_crosshairs.py
+ mv crosshairs.svg outputs
env:
TIKA_VERSION: 1.24 # Prevent delays in upgrades
DRIVE_API_KEY: ${{ secrets.DRIVE_API_KEY }}
diff --git a/covid_plot_utils.py b/covid_plot_utils.py
index 806d19eb..a39cc8de 100644
--- a/covid_plot_utils.py
+++ b/covid_plot_utils.py
@@ -1,8 +1,4 @@
-import html
-import json
import os
-import xml.etree.ElementTree as ET
-from io import BytesIO
from typing import Callable
from typing import List
from typing import Sequence
@@ -20,6 +16,7 @@
from matplotlib.offsetbox import TextArea
from matplotlib.ticker import FuncFormatter
+from matplotlib_svg_x_crosshairs import save_svg_with_crosshairs
from utils_pandas import get_cycle
from utils_pandas import human_format
from utils_pandas import perc_format
@@ -459,9 +456,10 @@ def plot_area(df: pd.DataFrame,
if ma_suffix:
act_cols = list(orig_cols) + ([unknown_name] if unknown_total else [])
act_df = df_plot[act_cols].applymap(lambda v: y_formatter(v, 0))
- svg_hover(plt, path, leg, stacked, sort_df, act_df, avg_df, labels=["", f"{ma_days}d avg"])
+ save_svg_with_crosshairs(plt, path, leg, sort_df, {
+ "": act_df, f"{ma_days}d avg": avg_df}, facecolor=theme_light_back, transparent=False)
else:
- svg_hover(plt, path, leg, stacked, sort_df, avg_df)
+ save_svg_with_crosshairs(plt, path, leg, sort_df, {"": avg_df}, facecolor=theme_light_back, transparent=False)
logger.info("Plot: {}", path)
plt.close()
@@ -727,165 +725,3 @@ def __init__(self, actual, label, color):
self.actual = actual
self.label = label
self.color = color
-
-
-def svg_hover(plt, path, legend, stacked, df, *displays, labels=[]):
- f = BytesIO()
- plt.savefig(f, format="svg", facecolor=theme_light_back, transparent=False)
-
- # Create XML tree from the SVG file.
- tree, xmlid = ET.XMLID(f.getvalue())
- tree.set('onload', 'init(event)')
-
- colours = []
- legends = []
- circles = []
- for number, patch in enumerate(legend.get_patches() or legend.get_lines()):
- text = legend.get_texts()[number].get_text()
- text = html.escape(text).encode('ascii', 'xmlcharrefreplace').decode("utf8")
- color = list(patch.get_facecolor() if hasattr(patch, "get_facecolor") else patch.get_color())
- legends.append(text)
- colour = matplotlib.colors.to_hex(color, keep_alpha=False)
- colours.append(colour)
- circles.append(f'')
-
- # insert svg to for tooltip in - https://codepen.io/billdwhite/pen/rgEbc
- linesvg = f"""
-
-
- {"".join(circles)}
-
- """
- xmlid["figure_1"].append(ET.XML(linesvg))
- tooltipsvg = f"""
-
-
-
-
-
-
-
- """
- xmlid["figure_1"].append(ET.XML(tooltipsvg))
- xmlid["figure_1"].set("fill", "black") # some browsers don't seem to respect background
-
- # This is the script defining the ShowTooltip and HideTooltip functions.
- script = """
-
- """
-
- # Insert the script at the top of the file and save it.
- tree.insert(0, ET.XML(script))
- tree.insert(0, ET.XML(''))
-
- ET.ElementTree(tree).write(path)
diff --git a/matplotlib_svg_x_crosshairs.py b/matplotlib_svg_x_crosshairs.py
new file mode 100644
index 00000000..bb7b9b04
--- /dev/null
+++ b/matplotlib_svg_x_crosshairs.py
@@ -0,0 +1,212 @@
+import html
+import json
+import xml.etree.ElementTree as ET
+from io import BytesIO
+
+import matplotlib
+
+
+def save_svg_with_crosshairs(plt, path, legend, x, series_table, series=None, **savefig_params):
+ """
+ Save the plt to path as an svg including javascript that shows an x crosshair giving
+ detail about each value at that x value. You can optionally display multiple values
+ for each series even if not included in the plot data. series_table is a dict of
+ "Column Name" -> values list.
+ """
+
+ # TODO: can we get x and series from the plt?
+ # if not display_dataframes:
+ # display_dataframes = {"": position_dataframe}
+
+ # TODO: get legend from plt. but how?
+
+ # Save as SVG so we can extend it with our crosshairs
+ f = BytesIO()
+ plt.savefig(f, format="svg", **savefig_params)
+
+ # Create XML tree from the SVG file.
+ tree, xmlid = ET.XMLID(f.getvalue())
+ tree.set('onload', 'init(event)')
+
+ # Get colours and labels for series displayed on the legend
+ colours = []
+ legends = []
+ for number, patch in enumerate(legend.get_patches() or legend.get_lines()):
+ text = legend.get_texts()[number].get_text()
+ text = html.escape(text).encode('ascii', 'xmlcharrefreplace').decode("utf8")
+ color = list(patch.get_facecolor() if hasattr(patch, "get_facecolor") else patch.get_color())
+ legends.append(text)
+ colour = matplotlib.colors.to_hex(color, keep_alpha=False)
+ colours.append(colour)
+
+ # Insert SVG for the line, circles and the tooltip itself
+ circles = [f'' for number, colour in enumerate(colours)]
+ tooltipsvg = f"""
+
+
+
+
+
+
+
+ """
+ linesvg = f"""
+
+
+ {"".join(circles)}
+
+ """
+ xmlid["figure_1"].append(ET.XML(tooltipsvg))
+ xmlid["figure_1"].append(ET.XML(linesvg))
+ xmlid["figure_1"].set("fill", "black") # some browsers don't seem to respect background
+
+ # Add the data into the script. handles either dataframes or lists
+ data = [d.to_json(orient="columns", date_format="iso") if hasattr(d, "to_json") else json.dumps(list(d))
+ for d in series_table.values()]
+ datajs = f"""
+ var x_index = {json.dumps(x)};
+ var data = [{",".join(data)}];
+ var series = {"null" if not series else json.dumps(series)};
+ var colours = {json.dumps(colours)};
+ var legends = {json.dumps(legends)};
+ var headings = {json.dumps(list(series_table.keys()))};
+ """
+
+ # Insert the script at the top of the file and save it.
+ tree.insert(
+ 0, ET.XML(f''))
+ tree.insert(0, ET.XML(''))
+
+ ET.ElementTree(tree).write(path)
+
+
+# This is the script for adding mousemove and mouseout to display the line, circles and tooltip at the right place
+SCRIPT = """
+ function init(event) {
+ var tooltip = d3.select("g.tooltip.mouse");
+ var line = d3.select("g#date_line line");
+ var plot = d3.select("#patch_2");
+ var offset = plot.node().getBBox().x;
+ var date_label = d3.select("#date");
+ // var border = d3.select("#tooltiprect");
+ var gap = 15;
+ let padding = 4;
+ if (!series) {
+ series = data[0];
+ }
+
+ d3.select("#figure_1").on("mousemove", function (evt) {
+ // from https://codepen.io/billdwhite/pen/rgEbc
+ tooltip.attr('visibility', "visible")
+ var plotpos = d3.pointer(evt, plot.node())[0] - offset;
+ var index = Math.round(plotpos / plot.node().getBBox().width * (x_index.length-1));
+ var date = x_index[index];
+ if (!date) {
+ tooltip.attr('visibility', "hidden");
+ d3.select("g#date_line").attr('visibility', "hidden");
+ d3.select("#legend_1").attr('visibility', "visible");
+ return;
+ }
+ else if ((typeof date === 'string' || date instanceof String) && date.includes("T")) {
+ // HACK: strip off timezone
+ date = date.split("T")[0];
+ }
+ //date_label.node().textContent = date;
+ values = [];
+ for ( let number = 0; number < legends.length; number++ ) {
+ var row = [series[index][number], legends[number], colours[number]];
+ for (let d = 0; d < data.length; d++) {
+ row.push(data[d][index][number])
+ }
+ values.push(row);
+ }
+ values.sort(function(a,b) {return a[0] - b[0]});
+ values.reverse();
+
+ table = ""+(new Date(date)).toDateString()+"";
+ for (let l = 0; l < headings.length; l++) {
+ table += ""+headings[l]+"";
+ }
+ table += "";
+ for (let col = 0; col < values.length; col++) {
+ var colour = values[col][2];
+ table += "" + values[col][1] + "";
+ for ( let number = 3; number < values[col].length; number++ ) {
+ table += "" + values[col][number] + "";
+ }
+ table += "";
+ }
+ d3.select("#tooltip_table").html(table);
+
+ var mouseCoords = d3.pointer(evt, tooltip.node().parentElement);
+ let tooltipbox = d3.select("#tooltiptext div").node();
+ let width = tooltipbox.clientWidth;
+ var x = mouseCoords[0] - width - gap*2;
+ if (x < 0) {
+ x = mouseCoords[0] + gap;
+ }
+ tooltip
+ .attr("transform", "translate("
+ + (x) + ","
+ + (mouseCoords[1] - tooltipbox.clientHeight/2) + ")");
+ line.attr("x1", mouseCoords[0]);
+ line.attr("x2", mouseCoords[0]);
+ let top = plot.node().getBBox().y;
+ let bottom = top + plot.node().getBBox().height;
+ line.attr("y1", top);
+ line.attr("y2", bottom);
+ d3.select("#date_line").attr('visibility', "visible");
+ d3.select("#legend_1").attr('visibility', "hidden");
+
+ // Move the dots
+ for (let col = 0; col < legends.length; col++) {
+ let dot = d3.select("#dot_"+col);
+ dot.attr('cy', bottom - (series[index][col] * (bottom - top)) );
+ dot.attr('cx', mouseCoords[0]);
+ if (series[index][col] == null) {
+ dot.attr("visibility", "hidden");
+ }
+ else {
+ dot.attr("visibility", "visible");
+ }
+ }
+
+
+ })
+ .on("mouseout", function () {
+ d3.select("#date_line").attr('visibility', "hidden");
+ return tooltip.attr('visibility', "hidden");
+ d3.select("#legend_1").attr('visibility', "visible");
+ });
+
+ }
+ """
+
+if __name__ == "__main__":
+ # %%
+ import matplotlib.pyplot as plt
+
+ # data from United Nations World Population Prospects (Revision 2019)
+ # https://population.un.org/wpp/, license: CC BY 3.0 IGO
+ year = [1950, 1960, 1970, 1980, 1990, 2000, 2010, 2018]
+ population_by_continent = {
+ 'africa': [228, 284, 365, 477, 631, 814, 1044, 1275],
+ 'americas': [340, 425, 519, 619, 727, 840, 943, 1006],
+ 'asia': [1394, 1686, 2120, 2625, 3202, 3714, 4169, 4560],
+ 'europe': [220, 253, 276, 295, 310, 303, 294, 293],
+ 'oceania': [12, 15, 19, 22, 26, 31, 36, 39],
+ }
+
+ fig, ax = plt.subplots()
+ ax.stackplot(year, population_by_continent.values(),
+ labels=population_by_continent.keys(), alpha=0.8)
+ legend = ax.legend(loc='upper left')
+ ax.set_title('World population')
+ ax.set_xlabel('Year')
+ ax.set_ylabel('Number of people (millions)')
+ # %%
+ save_svg_with_crosshairs(plt, "crosshairs.svg", legend, year, {"Population": population_by_continent.values()})
+ # plt.show()