Repository: benchopt/benchopt
Pull request #418 (2022-06-02 08:17:05)

[DRAFT] Improve plot display
THIS IS A DRAFT VERSION.

Instead of writing HTML plot for each combination of data, we use a unique plot and update it using JS.

- The constant `TEMPLATE_LOCAL_RESULT` seems to be unused.

- The link https://cdn.plot.ly/plotly-latest.min.js returns PlotlyJS V1 instead of V2.

codecov-commenter: # [Codecov](https://codecov.io/gh/benchopt/benchopt/pull/418?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=benchopt) Report
> Merging [#418](https://codecov.io/gh/benchopt/benchopt/pull/418?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=benchopt) (26fba74) into [main](https://codecov.io/gh/benchopt/benchopt/commit/899967d0b2eafc5cad2fbb565b0004e856fb1be3?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=benchopt) (899967d) will **decrease** coverage by `0.32%`.
> The diff coverage is `5.88%`.


```diff
@@            Coverage Diff             @@
##             main     #418      +/-   ##
==========================================
- Coverage   53.99%   53.67%   -0.33%     
==========================================
  Files          44       44              
  Lines        2667     2683      +16     
  Branches      506      510       +4     
==========================================
  Hits         1440     1440              
- Misses       1120     1136      +16     
  Partials      107      107              
```



tomMoral: Just tried it, this seems much smoother!! There are still some work but it is going in the right direction I think.
One think: you should store the median curve directly in the `json` data no? this way you won't have multiple curve per solver?
![image](https://user-images.githubusercontent.com/3321081/171913596-3a825e91-e1c2-4cb7-9da5-8559adb09c7d.png)

Maybe a nice thing would be to list the different steps that you think you will take in the PR description, so we can discuss them and see if anything is missing?
tomMoral: But I have to say, the loading seems super smooth!! :)

Patch:
diff --git a/benchopt/cli/main.py b/benchopt/cli/main.py
index 0754372..3f23758 100644
--- a/benchopt/cli/main.py
+++ b/benchopt/cli/main.py
@@ -9,6 +9,7 @@ from benchopt.cli.completion import complete_datasets
 from benchopt.cli.completion import complete_benchmarks
 from benchopt.cli.completion import complete_conda_envs
 from benchopt.cli.completion import complete_config_files
+from benchopt.utils.misc import get_benchopt_requirement
 from benchopt.utils.conda_env_cmd import list_conda_envs
 from benchopt.utils.conda_env_cmd import create_conda_env
 from benchopt.utils.shell_cmd import _run_shell_in_conda_env
@@ -273,11 +274,20 @@ def run(config_file=None, **kwargs):
     print(f"Launching benchopt in env {env_name}")
 
     # check if environment was set up with benchopt
-    if get_benchopt_version_in_env(env_name) is None:
+    benchopt_version, is_editable = get_benchopt_version_in_env(env_name)
+    if benchopt_version is None:
         raise RuntimeError(
             f"benchopt is not installed in env '{env_name}', "
             "see the command `benchopt install` to setup the environment."
         )
+    # check against running version
+    from benchopt import __version__ as benchopt_version_running
+    _, is_editable_running = get_benchopt_requirement()
+    if (benchopt_version_running != benchopt_version and not
+            (is_editable_running and is_editable)):
+        warnings.warn(
+            f"Benchopt running version ({benchopt_version_running}) "
+            f"and version in env {env_name} ({benchopt_version}) differ")
 
     # run the command in the conda env
     solvers_option = ' '.join([f"-s '{s}'" for s in solver_names])
diff --git a/benchopt/cli/process_results.py b/benchopt/cli/process_results.py
index a15b257..a20eda7 100644
--- a/benchopt/cli/process_results.py
+++ b/benchopt/cli/process_results.py
@@ -1,4 +1,5 @@
 import click
+import warnings
 from pathlib import Path
 
 from benchopt.config import get_setting
@@ -56,6 +57,10 @@ def plot(benchmark, filename=None, kinds=('suboptimality_curve',),
         assert html, '`--all` can only be used for HTML plot generation.'
         filename = 'all'
 
+    if html and len(kinds) > 0:
+        warnings.warn("Cannot specify '--kind' for HTML plot, this options "
+                      "will be ignored.")
+
     # Get the result file
     benchmark = Benchmark(benchmark)
     result_filename = benchmark.get_result_file(filename)
diff --git a/benchopt/plotting/generate_html.py b/benchopt/plotting/generate_html.py
index 15860d0..d212b88 100644
--- a/benchopt/plotting/generate_html.py
+++ b/benchopt/plotting/generate_html.py
@@ -1,19 +1,17 @@
 import json
 import shutil
-import itertools
 import webbrowser
 from pathlib import Path
 from datetime import datetime
 
 import pandas as pd
-import matplotlib.pyplot as plt
 from mako.template import Template
 
 from ..constants import PLOT_KINDS
-from .plot_bar_chart import plot_bar_chart  # noqa: F401
-from .plot_objective_curve import plot_objective_curve  # noqa: F401
-from .plot_objective_curve import plot_suboptimality_curve  # noqa: F401
-from .plot_objective_curve import plot_relative_suboptimality_curve  # noqa: F401 E501
+from .plot_bar_chart import computeBarChartData  # noqa: F401
+from .plot_objective_curve import compute_quantiles   # noqa: F401
+from .plot_objective_curve import get_solver_color   # noqa: F401
+from .plot_objective_curve import get_solver_marker  # noqa: F401
 
 
 ROOT = Path(__file__).parent / "html"
@@ -24,7 +22,6 @@ FIGURES = "figures"
 TEMPLATE_INDEX = ROOT / "templates" / "index.mako.html"
 TEMPLATE_BENCHMARK = ROOT / "templates" / "benchmark.mako.html"
 TEMPLATE_RESULT = ROOT / "templates" / "result.mako.html"
-TEMPLATE_LOCAL_RESULT = ROOT / "templates" / "local_result.mako.html"
 
 SYS_INFO = {
     "main": [('system-cpus', 'cpu'),
@@ -41,112 +38,6 @@ SYS_INFO = {
 }
 
 
-def generate_plot_benchmark(df, kinds, fname, fig_dir, benchmark_name):
-    """Generate all possible plots for a given benchmark run.
-
-    Parameters
-    ----------
-    df : instance of pandas.DataFrame
-        The benchmark results.
-    kinds : list of str
-        List of the kind of plots that will be generated. This needs to be a
-        sub-list of PLOT_KINDS.keys().
-    fname: str
-        CSV file name.
-    fig_dir : Path
-        Base directory to save figures.
-    benchmark_name : str
-        Name of the benchmark, to prefix the file names.
-
-    Returns
-    -------
-    dict
-        The figures for convergence curves and bar charts
-        for each dataset.
-    """
-    dataset_names = df['data_name'].unique()
-    objective_names = df['objective_name'].unique()
-    obj_cols = [
-        k for k in df.columns
-        if k.startswith('objective_') and k != 'objective_name'
-    ]
-
-    figures = {}
-    n_figure = 0
-    for data_name in dataset_names:
-        figures[data_name] = {}
-        df_data = df[df['data_name'] == data_name]
-        for objective_name in objective_names:
-            figures[data_name][objective_name] = {c: {} for c in obj_cols}
-            df_obj = df_data[df_data['objective_name'] == objective_name]
-
-            for k, obj_col in itertools.product(kinds, obj_cols):
-                if k not in PLOT_KINDS:
-                    raise ValueError(
-                        f"Requesting invalid plot '{k}'. Should be in:\n"
-                        f"{PLOT_KINDS}")
-                plot_func = globals()[PLOT_KINDS[k]]
-                try:
-                    fig = plot_func(df_obj, obj_col=obj_col, plotly=True)
-                    if PLOT_KINDS[k] == "plot_bar_chart":
-                        fig.update_layout(autosize=False,
-                                          width=900,
-                                          height=650)
-                    else:
-                        if len(df_obj["solver_name"].unique()) < 10:
-                            fact_ = 10
-                        else:
-                            fact_ = 100
-                        height = 700 + fact_ * len(objective_names)
-                        fig.update_layout(legend={"xanchor": "center",
-                                                  "yanchor": "top",
-                                                  "y": -.2,
-                                                  "x": .5
-                                                  },
-                                          autosize=False,
-                                          width=900,
-                                          height=height
-                                          )
-                except TypeError:
-                    fig = plot_func(df_obj, obj_col=obj_col)
-                figures[data_name][objective_name][obj_col][k] = export_figure(
-                    fig, f"{benchmark_name}_{fname.name}_{n_figure}", fig_dir
-                )
-                n_figure += 1
-
-    return dict(
-        figures=figures, dataset_names=dataset_names, fname_short=fname.name,
-        objective_names=objective_names, obj_cols=obj_cols, kinds=list(kinds)
-    )
-
-
-def export_figure(fig, fig_name, fig_dir):
-    """Export a figure to HTML or svg.
-
-    Parameters
-    ----------
-    fig : plotly.graph_objs.Figure
-        Figure from plotly
-    fig_name : str
-        Name to be given to the figure.
-    fig_dir : Path
-        Base directory to save figures.
-
-    Returns
-    -------
-    save_name : str
-        Path to the saved figure.
-    """
-    if hasattr(fig, 'to_html'):
-        return fig.to_html(include_plotlyjs=False)
-
-    fig_basename = f"{fig_name}.svg"
-    save_name = fig_dir / fig_basename
-    fig.savefig(save_name)
-    plt.close(fig)
-    return str(save_name)
-
-
 def get_results(fnames, kinds, root_html, benchmark_name, copy=False):
     """Generate figures from a list of csv files.
 
@@ -172,7 +63,6 @@ def get_results(fnames, kinds, root_html, benchmark_name, copy=False):
         generated figures.
     """
     results = []
-    fig_dir = root_html / FIGURES
     out_dir = root_html / OUTPUTS
 
     for fname in fnames:
@@ -190,10 +80,15 @@ def get_results(fnames, kinds, root_html, benchmark_name, copy=False):
 
         # Generate figures
         result = dict(
-            fname=fname, datasets=datasets, sysinfo=sysinfo,
-            **generate_plot_benchmark(
-                df, kinds, fname, fig_dir, benchmark_name
-            )
+            fname=fname,
+            fname_short=fname.name,
+            datasets=datasets,
+            sysinfo=sysinfo,
+            dataset_names=df['data_name'].unique(),
+            objective_names=df['objective_name'].unique(),
+            obj_cols=[k for k in df.columns if k.startswith('objective_')
+                      and k != 'objective_name'],
+            kinds=list(kinds),
         )
         results.append(result)
 
@@ -203,9 +98,82 @@ def get_results(fnames, kinds, root_html, benchmark_name, copy=False):
             f"{result['fname_short'].replace('.csv', '.html')}"
         )
 
+        result['json'] = json.dumps(shape_datasets_for_html(df))
+
     return results
 
 
+def shape_datasets_for_html(df):
+    """Return a dictionary with plotting data for each dataset."""
+    datasets_data = {}
+
+    for dataset in df['data_name'].unique():
+        datasets_data[dataset] = shape_objectives_for_html(df, dataset)
+
+    return datasets_data
+
+
+def shape_objectives_for_html(df, dataset):
+    """Return a dictionary with plotting data for each objective."""
+    objectives_data = {}
+
+    for objective in df['objective_name'].unique():
+        objectives_data[objective] = shape_objectives_columns_for_html(
+            df, dataset, objective)
+
+    return objectives_data
+
+
+def shape_objectives_columns_for_html(df, dataset, objective):
+    """Return a dictionary with plotting data for each objective column."""
+    objective_columns_data = {}
+    columns = [
+        c for c in df.columns
+        if c.startswith('objective_') and c != 'objective_name'
+    ]
+    for column in columns:
+        df_filtered = df.query(
+            "data_name == @dataset & objective_name == @objective"
+        )
+        objective_columns_data[column] = {
+            'solvers': shape_solvers_for_html(df_filtered, column),
+            # Values used in javascript to do computation
+            'transformers': {
+                'c_star': float(df_filtered[column].min() - 1e-10),
+                'max_f_0': float(
+                    df_filtered[df_filtered['stop_val'] == 1][column].max()
+                )
+            }
+        }
+
+    return objective_columns_data
+
+
+def shape_solvers_for_html(df, objective_column):
+    """Return a dictionary with plotting data for each solver."""
+    solver_data = {}
+    for solver in df['solver_name'].unique():
+        df_filtered = df.query("solver_name == @solver")
+        q1, q9 = compute_quantiles(df_filtered)
+        solver_data[solver] = {
+            'scatter': {
+                'x': df_filtered.groupby('stop_val')['time']
+                                .median().tolist(),
+                'y': df_filtered.groupby('stop_val')[objective_column]
+                                .median().tolist(),
+                'q1': q1.tolist(),
+                'q9': q9.tolist(),
+            },
+            'bar': {
+                **computeBarChartData(df, objective_column, solver)
+            },
+            'color': get_solver_color(solver),
+            'marker': get_solver_marker(solver)
+        }
+
+    return solver_data
+
+
 def get_sysinfo(df):
     """Get a dictionnary of the recorded system informations.
 
@@ -439,6 +407,7 @@ def plot_benchmark_html(fnames, benchmark, kinds, display=True):
     with open(bench_index, "w", encoding="utf-8") as f:
         f.write(rendered)
 
+    print("Rendering benchmark results...")
     # Display the file in the default browser
     if display:
         result_filename = (root_html / results[-1]['page']).absolute()
@@ -447,7 +416,7 @@ def plot_benchmark_html(fnames, benchmark, kinds, display=True):
 
 def plot_benchmark_html_all(patterns=(), benchmarks=(), root=None,
                             display=True):
-    """Generate a HTML rerport for multiple benchmarks.
+    """Generate a HTML report for multiple benchmarks.
 
     This utility is the one used to create https://benchopt.github.io/results.
     It will open all benchmarks in `root` and create a website to browse them.
diff --git a/benchopt/plotting/html/static/main.css b/benchopt/plotting/html/static/main.css
index 1824ed9..15a2974 100644
--- a/benchopt/plotting/html/static/main.css
+++ b/benchopt/plotting/html/static/main.css
@@ -258,6 +258,13 @@ td.number {
   font-size: 16px; /* Set a font size */
   cursor: pointer; /* Mouse pointer on hover */
   min-height: 46px;
+  /**
+   * Vertically align the icon and the text
+   * inside the button
+   */
+  display: inline-flex;
+  flex-direction: row;
+  align-items: center;
 }
 
 /* Darker background on mouse-over */
@@ -272,3 +279,22 @@ td.number {
   height: 1000px;
 }
 
+.btn svg {
+  width: 1.5rem;
+  height: 1.5rem;
+  color: white;
+}
+
+/* Select all elements after any other element in a .btn */
+.btn > * + * {
+  margin-left: 0.5rem;
+}
+
+#btn_subinfo:hover {
+  cursor: pointer;
+}
+
+#btn_subinfo svg {
+  width: 1rem;
+  height: 1rem;
+}
diff --git a/benchopt/plotting/html/static/result.js b/benchopt/plotting/html/static/result.js
index 8a9db76..146d15e 100644
--- a/benchopt/plotting/html/static/result.js
+++ b/benchopt/plotting/html/static/result.js
@@ -1,182 +1,510 @@
-var globalState = {
-  dataset_selector: [,],
-  objective_selector: [,],
-  objective_column: [,],
-  plot_kind: [,],
-}; // Object storing previous and current value of the dropdown selectors
+const NON_CONVERGENT_COLOR = 'rgba(0.8627, 0.8627, 0.8627)'
 
-/**
- * Initialize the global state of the selectors
- */
-$(function () {
-  const selectors = [
-    "dataset_selector",
-    "objective_selector",
-    "objective_column",
-    "plot_kind",
-  ];
-  for (sel of selectors) {
-    obj = document.getElementById(sel);
-    globalState[sel][1] = globalState[sel][0] = obj.value;
-    obj.attributes.counter = 0; // initialize dropdown counter
-    showMe(obj); // display initial figure + one pass on all dropdowns
-  }
-});
+/*
+ * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ * STATE MANAGEMENT
+ * 
+ * The state represent the plot state. It's an object
+ * that is stored into the window.state variable.
+ * 
+ * Do not manually update window.state,
+ * instead, foreach state modification,
+ * you shoud call the setState() function
+ * to keep the plot in sync with its state.
+ * 
+ * The state contains the following keys :
+ *   - dataset (string),
+ *   - objective (string),
+ *   - objective_column (string),
+ *   - plot_kind (string),
+ *   - scale (string)
+ *   - with_quantiles (boolean)
+ *   - hidden_solvers (array)
+ * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ */
 
 /**
- * Get the id of the div containing the plotly graph div
- * @param  {String} which previous for previous graph state, anything for current
- * @return {String}       concatenated dataset,objectives and plot kind as id
- */
-function getId(which) {
-  if (which === "previous") index = 0;
-  // previous states
-  else index = 1; // new states
-  return Object.values(globalState) // all values of selectors
-    .map(function (value) {
-      return value[index]; // select only column of interest
-    })
-    .join(""); // join as string for complete id
+ * Update the state and create/update the plot
+ * using the new state.
+ * 
+ * @param {Object} partialState 
+ */
+const setState = (partialState, updatePlot = true) => {
+  window.state = {...state(), ...partialState};
+  displayScaleSelector(!isBarChart());
+  if (updatePlot) makePlot();
 }
 
 /**
- * Only show the selected graph from dropdown menus
- */
-function showMe(e) {
-  if (globalState[e.id][1] !== "bar_chart") {
-    Object.values(globalState).forEach((arr, _) => {
-      arr[0] = arr[1];
-    }); // shift all new values to only replace one after
-    globalState[e.id].shift(); // previous value is now at index 0
-  }
-  if (e.id === "dataset_selector") {
-    document.getElementById("change_scaling").value = "semilog-y"; // default
-  }
-  globalState[e.id][1] = e.value; // set new value of selector
-  for (let opt of e.options) {
-    allObj = document.getElementsByClassName(opt.value); // get all divs corresponding
-    for (let obj of allObj) {
-      if (opt.value === e.value) obj.style.display = "block";
-      else obj.style.display = "none"; // hide non necessary divs
+ * Retrieve the state object from window.state
+ * 
+ * @returns Object
+ */
+const state = () => window.state;
+
+/*
+ * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ * PLOT MANAGEMENT
+ * 
+ * Retrieve formatted data for PlotlyJS using state
+ * and create/update the plot.
+ * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ */
+
+/**
+ * Create/Update the plot.
+ */
+const makePlot = () => {
+  const div = document.getElementById('unique_plot');
+  const data = isBarChart() ? getBarData() : getScatterCurves();
+  const layout = isBarChart() ? getBarChartLayout() : getScatterChartLayout();
+
+  Plotly.react(div, data, layout);
+};
+
+/**
+ * Gives the data formatted for plotlyJS bar chart.
+ * 
+ * @returns {array}
+ */
+const getBarData = () => {
+  if (!isAvailable()) return [{type:'bar'}];
+
+  const {x, y, colors, texts} = barDataToArrays()
+
+  // Add bars
+  const barData = [{
+    type: 'bar',
+    x: x,
+    y: y,
+    marker: {
+      color: colors,
+    },
+    text: texts,
+    textposition: 'inside',
+    insidetextanchor: 'middle',
+    textangle: '-90',
+  }];
+
+  x.forEach(solver => {
+    // Add times for each convergent solver
+    // Check if text is not 'Did not converge'
+    if (data(solver).bar.text === '') {
+      let nbTimes = data(solver).bar.times.length
+
+      barData.push({
+        type: 'scatter',
+        x: new Array(nbTimes).fill(solver),
+        y: data(solver).bar.times,
+        marker: {
+          color: 'black',
+          symbol: 'line-ew-open'
+        },
+        line: {
+          width: 0
+        },
+      });
     }
-  }
-  if (
-    e.attributes.counter > 0 &&
-    globalState.dataset_selector[0] === globalState.dataset_selector[1]
-  ) {
-    // if at least another graph was displayed before (initialized)
-    visibleTraces(); // keep traces coherent accross graphs
-  }
-  if (
-    e.attributes.counter > 0
-  ) {
-    toggleShades();  // keep quantile curves coherent (must be after visibleTraces) and not dataset_selector dependent
-  }
-  e.attributes.counter += 1; // out of initialization
-}
+  });
+
+  return barData;
+};
 
-// update the traces accross the different plots
-function visibleTraces() {
-  prevId = getId("previous"); // id of previous graph
-  nowId = getId("now"); // id of current graph
-  if (globalState.plot_kind[1] !== "bar_chart") {
-    // only check name not type
-    graph = document.getElementById(
-      document.getElementById(prevId).getElementsByTagName("div")[1].id
-    ); // get revious plotly figure
-    tracesVisib = graph.data.map((trace) => trace.visible); // get previous visible traces
-    graph = document.getElementById(
-      document.getElementById(nowId).getElementsByTagName("div")[1].id
-    );
-    for (i = 0; i < graph.data.length; i++) {
-      graph.data[i].visible = tracesVisib[i]; // set visible traces for new graph
+/**
+ * Gives the data formatted for plotlyJS scatter chart.
+ * 
+ * @returns {array}
+ */
+const getScatterCurves = () => {
+  // create a list of object to plot in plotly
+  const curves = [];
+
+  // For each solver, add the median curve with proper style and visibility.
+  getSolvers().forEach(solver => {
+    curves.push({
+      type: 'scatter',
+      name: solver,
+      mode: 'lines+markers',
+      line: {
+        color: data(solver).color,
+      },
+      marker: {
+        symbol: data(solver).marker,
+        size: 10,
+      },
+      legendgroup: solver,
+      hovertemplate: solver + ' <br> (%{x:.1e},%{y:.1e}) <extra></extra>',
+      visible: isVisible(solver) ? true : 'legendonly',
+      x: data(solver).scatter.x,
+      y: useTransformer(data(solver).scatter.y, 'y', data().transformers),
+    });
+
+    if (state().with_quantiles) {
+      // Add shaded area for each solver, with proper style and visibility.
+      curves.push({
+        type: 'scatter',
+        mode: 'lines',
+        showlegend: false,
+        line: {
+          width: 0,
+          color: data(solver).color,
+        },
+        legendgroup: solver,
+        hovertemplate: '(%{x:.1e},%{y:.1e}) <extra></extra>',
+        visible: isVisible(solver) ? true : 'legendonly',
+        x: data(solver).scatter.q1,
+        y: data(solver).scatter.y,
+      }, {
+        type: 'scatter',
+        mode: 'lines',
+        showlegend: false,
+        fill: 'tonextx',
+        line: {
+          width: 0,
+          color: data(solver).color,
+        },
+        legendgroup: solver,
+        hovertemplate: '(%{x:.1e},%{y:.1e}) <extra></extra>',
+        visible: isVisible(solver) ? true : 'legendonly',
+        x: data(solver).scatter.q9,
+        y: data(solver).scatter.y,
+      });
     }
-    Plotly.redraw(graph); // redraw with cohesive visible traces
+  });
+
+  return curves;
+};
+
+/*
+ * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ * DATA TRANSFORMERS
+ * 
+ * Transformers are used to modify data
+ * on the fly.
+ * 
+ * WARNING : If you add a new transformer function,
+ * don't forget to register it in the object : window.tranformers,
+ * at the end of this section.
+ * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ */
+
+/**
+ * Select the right transformer according to the state.
+ * If the requested transformer does not exists,
+ * it returns raw data.
+ * 
+ * @param {Object} data 
+ * @param {String} solver 
+ * @param {String} axis it could be x, y, q1, q9
+ * @returns {array}
+ */
+const useTransformer = (data, axis, options) => {
+  try {
+    let transformer = 'transformer_' + axis + '_' + state().plot_kind;
+    return window.transformers[transformer](data, options);
+  } catch(error) {
+    return data;
   }
+};
+
+/**
+ * Transform data on the y axis for subotimality curve.
+ * 
+ * @param {Object} data 
+ * @param {String} solver 
+ * @returns {array}
+ */
+const transformer_y_suboptimality_curve = (y, options) => {
+  // Retrieve c_star value
+  const c_star = options.c_star;
+  
+  // Compute suboptimality for each data
+  return y.map(value => value - c_star);
+};
+
+/**
+ * Transform data ont the y axis for relative suboptimality curve.
+ * 
+ * @param {Object} data 
+ * @param {String} solver 
+ * @returns {array}
+ */
+const transformer_y_relative_suboptimality_curve = (y, options) => {
+  // Retrieve transformer values
+  const c_star = options.c_star;
+  const max_f_0 = options.max_f_0;
+
+  // Compute relative suboptimality for each data
+  return y.map(value => (value - c_star) / (max_f_0 - c_star));
+};
+
+/**
+ * Store all the transformer functions to be callable
+ * by the useTransformer function.
+ */
+window.transformers = {
+  transformer_y_suboptimality_curve,
+  transformer_y_relative_suboptimality_curve,
+};
+
+/*
+ * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ * TOOLS
+ * 
+ * Various functions to simplify life.
+ * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ */
+
+const data = (solver = null) => {
+  return solver ?
+    window.data[state().dataset][state().objective][state().objective_column].solvers[solver]:
+    window.data[state().dataset][state().objective][state().objective_column]
 }
 
+const getSolvers = () => Object.keys(data().solvers);
+
+const isBarChart = () => state().plot_kind === 'bar_chart';
+
+const isVisible = solver => !state().hidden_solvers.includes(solver);
+
+const isSolverAvailable = solver => data(solver).scatter.y.filter(value => !isNaN(value)).length > 0
+
 /**
- * Change y axis scale of plotly graph (loglog <-> semilog-y)
- * @param  {Object} e  dropdown for axis log type
- */
-function changeScale(e) {
-  allContainers = document
-    .getElementsByClassName(globalState["dataset_selector"][1])[0]
-    .getElementsByClassName("plot-container plotly");
-  for (which = 0; which < allContainers.length; which++) {
-    graph = allContainers[which].parentNode; // get plotly graph to change
-    if (!graph.parentNode.parentNode.id.endsWith("bar_chart")) {
-      layout = graph.layout; // get layout to recover only axis
-      switch (e.value) {
-        case "loglog":
-          layout.xaxis.type = "log";
-          layout.yaxis.type = "log";
-          break;
-        case "semilog-y":
-          layout.xaxis.type = "linear";
-          layout.yaxis.type = "log";
-          break;
-        case "semilog-x":
-          layout.xaxis.type = "log";
-          layout.yaxis.type = "linear";
-          break;
-        case "linear":
-          layout.xaxis.type = "linear";
-          layout.yaxis.type = "linear";
-          break;
-      }
-      Plotly.relayout(graph.id, layout); // change axis type of plot
+ * Check for each solver
+ * if data is available
+ * 
+ * @returns {Boolean}
+ */
+const isAvailable = () => {
+  let isNotAvailable = true;
+
+  getSolvers().forEach(solver => {
+    if (isSolverAvailable(solver)) {
+      isNotAvailable = false;
     }
-  }
+  });
+
+  return !isNotAvailable;
 }
 
-// modify the + into a - when clicking to show more system informations
-$(".toggle").click(function () {
-  $(this).find("svg").toggleClass("fa-plus-circle fa-minus-circle");
-  x = document.getElementById("subinfo");
-  if (x.style.display === "none") {
-    x.style.display = "block";
-  } else {
-    x.style.display = "none";
+const displayScaleSelector = shouldBeVisible => shouldBeVisible ?
+  document.getElementById('change_scaling').style.display = 'inline-block'
+  : document.getElementById('change_scaling').style.display = 'none';
+
+const barDataToArrays = () => {
+  const colors = [], texts = [], x = [], y = [];
+
+  getSolvers().forEach(solver => {
+    x.push(solver);
+    y.push(data(solver).bar.y);
+    colors.push(data(solver).bar.text === '' ? data(solver).color : NON_CONVERGENT_COLOR);
+    texts.push(data(solver).bar.text);
+  });
+
+  return {x, y, colors, texts}
+}
+
+const getScale = () => {
+  switch (state().scale) {
+    case 'loglog':
+      return {
+        xaxis: 'log',
+        yaxis: 'log',
+      };
+    case "semilog-y":
+      return {
+        xaxis: 'linear',
+        yaxis: 'log',
+      };
+    case "semilog-x":
+      return {
+        xaxis: 'log',
+        yaxis: 'linear',
+      };
+    case "linear":
+      return {
+        xaxis: 'linear',
+        yaxis: 'linear',
+      };
+    default:
+      console.error('Unknown scale value : ' + state().scale);
   }
-});
+}
+
+const getScatterChartLayout = () => {
+  const layout = {
+    width: 900,
+    height: 700,
+    autosize: false,
+    legend: {
+      title: {
+        text: 'Solvers',
+      },
+      orientation: 'h',
+      xanchor: 'center',
+      yanchor: 'top',
+      y: -.2,
+      x: .5
+    },
+    xaxis: {
+      type: getScale().xaxis,
+      title: 'Time [sec]',
+      tickformat: '.1e',
+      tickangle: -45,
+      gridcolor: '#ffffff',
+      zeroline : false,
+    },
+    yaxis: {
+      type: getScale().yaxis,
+      title: getYLabel(),
+      tickformat: '.1e',
+      gridcolor: '#ffffff',
+      zeroline : false,
+    },
+    title: `${state().objective}\nData: ${state().dataset}`,
+    plot_bgcolor: '#e5ecf6',
+  };
 
-/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-* Toggle shades on/off on plotly graph
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/
-function toggleShades() {
-  toggler = document.getElementById("change_shades");
-  if (toggler.checked === true) {
-    visible = true;
-  } else {
-    visible = false;
-  } // hide or show traces depending on toggler state
-
-  nowId = getId("now"); // id of current graph
-  graph = document.getElementById(
-    document.getElementById(nowId).getElementsByTagName("div")[1].id
-  );
-  allTraces = graph.data;
-  const allIndex = (arr) => {
-    return arr.map((elm, idx) => {
-      group = elm.legendgroup;
-      main = arr.find(function (el) { return el.legendgroup === group });
-      if ([undefined, true].includes(main.visible)) {
-        return elm.name == null ? idx : "";
+  if (!isAvailable()) {
+    layout.annotations = [{
+      xref: 'paper',
+      yref: 'paper',
+      x: 0.5,
+      y: 0.5,
+      text: 'Not available',
+      showarrow: false,
+      font: {
+        color: 'black',
+        size: 32,
       }
-      else{ return "" }
-    }
-      ).filter(String);
+    }];
+  };
+
+  return layout;
+};
+
+const getBarChartLayout = () => {
+  const layout = {
+    width: 900,
+    height: 700,
+    autosize: false,
+    yaxis: {
+      type: 'log',
+      title: 'Time [sec]',
+      tickformat: '.1e',
+      gridcolor: '#ffffff',
+    },
+    xaxis: {
+      tickangle: -60,
+      ticktext: getSolvers(),
+    },
+    showlegend: false,
+    title: `${state().objective}\nData: ${state().dataset}`,
+    plot_bgcolor: '#e5ecf6',
+  };
+
+  if (!isAvailable()) {
+    layout.annotations = [{
+      xref: 'paper',
+      yref: 'paper',
+      x: 0.5,
+      y: 0.5,
+      text: 'Not available',
+      showarrow: false,
+      font: {
+        color: 'black',
+        size: 32,
+      }
+    }];
+  };
+
+  return layout;
+};
+
+const getYLabel = () => {
+  switch(state().plot_kind) {
+    case 'objective_curve':
+      return 'F(x)';
+    case 'suboptimality_curve':
+      return 'F(x) - F(x*)';
+    case 'relative_suboptimality_curve':
+      return 'F(x) - F(x*) / F(x0) - F(x*)'
+    case 'bar_chart':
+      return 'Time [sec]';
+    default:
+      return 'unknown';
   };
-  whereToggle = allIndex(allTraces); // shade fills are without name
-  if (globalState.plot_kind[1] !== "bar_chart") {
-    Plotly.restyle(graph, { visible: visible }, whereToggle); // toggle visibility
+};
+
+/*
+ * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ * MANAGE HIDDEN SOLVERS
+ * 
+ * Functions to hide/display and memorize solvers which were clicked
+ * by user on the legend of the plot.
+ * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ */
+const getSolverFromPlotlyEvent = event => event.data[event.curveNumber].name;
+
+const purgeHiddenSolvers = () => setState({hidden_solvers: []}, false);
+
+const hideAllSolversExcept = solver => {setState({hidden_solvers: getSolvers().filter(elmt => elmt !== solver)}, false)};
+
+const hideSolver = solver => isVisible(solver) ? setState({hidden_solvers: [...state().hidden_solvers, solver]}, false) : null;
+
+const showSolver = solver => setState({hidden_solvers: state().hidden_solvers.filter(hidden => hidden !== solver)}, false);
+
+/**
+ * Add or remove solver name from the list of hidden solvers.
+ * 
+ * @param {String} solver
+ * @returns {void}
+ */
+const handleSolverClick = solver => {
+  if (!isVisible(solver)) {
+    showSolver(solver);
+
+    return;
+  }
+
+  hideSolver(solver);
+};
+
+const handleSolverDoubleClick = solver => {
+  if (!isVisible(solver)) {
+    purgeHiddenSolvers();
+
+    return;
+  }
+
+  hideAllSolversExcept(solver);
+};
+
+/**
+ * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ * EVENT REGISTRATIONS
+ * 
+ * Some events are also registered in result.html.
+ * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ */
+
+/**
+ * Listener on the system information "+" button
+ */
+document.getElementById('btn_subinfo').addEventListener('click', event => {
+  const elmt = document.getElementById('subinfo');
+  const plus = document.getElementById('btn_plus');
+  const minus = document.getElementById('btn_minus');
+
+  if (elmt.style.display === 'none') {
+      elmt.style.display = 'block';
+      plus.style.display = 'none';
+      minus.style.display = 'inline';
+
+      return;
   }
-}
 
-/*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-* Toggle shades on/off on click
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/
-$(function () {
-  $("#change_shades").change(toggleShades);
+  elmt.style.display = 'none';
+  plus.style.display = 'inline';
+  minus.style.display = 'none';
 });
diff --git a/benchopt/plotting/html/templates/result.mako.html b/benchopt/plotting/html/templates/result.mako.html
index 06e986a..6a7b476 100644
--- a/benchopt/plotting/html/templates/result.mako.html
+++ b/benchopt/plotting/html/templates/result.mako.html
@@ -1,138 +1,152 @@
+<%
+    benchmark_name = benchmark.replace("benchmark_", "").replace("_", " ")
+%>
+<!DOCTYPE html>
 <html>
+    <head>
+        <meta charset="utf-8" />
+        <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📈</text></svg>">
+        <link href="${static_dir}/main.css" rel="stylesheet" />
+        <link href="${static_dir}/hover_index.css" rel="stylesheet" />
+        <meta name="viewport" content="width=device-width, initial-scale=1.0">
 
-<head>
-    <meta charset="utf-8" />
-    <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📈</text></svg>">
-    <link href="${static_dir}/main.css" rel="stylesheet" />
-    <link href="${static_dir}/hover_index.css" rel="stylesheet" />
-
-    <script defer src="https://use.fontawesome.com/releases/v5.0.10/js/all.js" integrity="sha384-slN8GvtUJGnv6ca26v8EzVaR9DC58QEwsIk9q1QXdCU8Yu8ck/tL/5szYlBbqmS+" crossorigin="anonymous"></script>
-    <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.css" rel="stylesheet" />
-    <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
-    <script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
-    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+        <title>Result on ${benchmark_name} benchmark</title>
+    </head>
+    <body>
+        <a id="top"></a>
+        <a href="${home}">
+            <button class="btn">
+                <svg xmlns="http://www.w3.org/2000/svg" fill="#ffffff" viewBox="0 0 576 512"><path d="M511.8 287.6L512.5 447.7C512.5 450.5 512.3 453.1 512 455.8V472C512 494.1 494.1 512 472 512H456C454.9 512 453.8 511.1 452.7 511.9C451.3 511.1 449.9 512 448.5 512H392C369.9 512 352 494.1 352 472V384C352 366.3 337.7 352 320 352H256C238.3 352 224 366.3 224 384V472C224 494.1 206.1 512 184 512H128.1C126.6 512 125.1 511.9 123.6 511.8C122.4 511.9 121.2 512 120 512H104C81.91 512 64 494.1 64 472V360C64 359.1 64.03 358.1 64.09 357.2V287.6H32.05C14.02 287.6 0 273.5 0 255.5C0 246.5 3.004 238.5 10.01 231.5L266.4 8.016C273.4 1.002 281.4 0 288.4 0C295.4 0 303.4 2.004 309.5 7.014L416 100.7V64C416 46.33 430.3 32 448 32H480C497.7 32 512 46.33 512 64V185L564.8 231.5C572.8 238.5 576.9 246.5 575.8 255.5C575.8 273.5 560.8 287.6 543.8 287.6L511.8 287.6z"/></svg>
+            </button>
+        </a>
+        <a href="${benchmark}.html">
+            <button class="btn">
+                <svg xmlns="http://www.w3.org/2000/svg" fill="#ffffff" viewBox="0 0 448 512"><path d="M447.1 256C447.1 273.7 433.7 288 416 288H109.3l105.4 105.4c12.5 12.5 12.5 32.75 0 45.25C208.4 444.9 200.2 448 192 448s-16.38-3.125-22.62-9.375l-160-160c-12.5-12.5-12.5-32.75 0-45.25l160-160c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25L109.3 224H416C433.7 224 447.1 238.3 447.1 256z"/></svg>
+                <span>
+                    ${benchmark_name}
+                </span>
+            </button>
+        </a>
+        <a href="${result['fname']}">
+            <button class="btn">
+                <svg xmlns="http://www.w3.org/2000/svg" fill="#ffffff" viewBox="0 0 512 512"><path d="M480 352h-133.5l-45.25 45.25C289.2 409.3 273.1 416 256 416s-33.16-6.656-45.25-18.75L165.5 352H32c-17.67 0-32 14.33-32 32v96c0 17.67 14.33 32 32 32h448c17.67 0 32-14.33 32-32v-96C512 366.3 497.7 352 480 352zM432 456c-13.2 0-24-10.8-24-24c0-13.2 10.8-24 24-24s24 10.8 24 24C456 445.2 445.2 456 432 456zM233.4 374.6C239.6 380.9 247.8 384 256 384s16.38-3.125 22.62-9.375l128-128c12.49-12.5 12.49-32.75 0-45.25c-12.5-12.5-32.76-12.5-45.25 0L288 274.8V32c0-17.67-14.33-32-32-32C238.3 0 224 14.33 224 32v242.8L150.6 201.4c-12.49-12.5-32.75-12.5-45.25 0c-12.49 12.5-12.49 32.75 0 45.25L233.4 374.6z"/></svg>
+                <span>
+                    ${result["fname_short"]}
+                </span>
+            </button>
+        </a>
+        <h1 style:>Result on ${benchmark_name} benchmark</h1>
+        <summary></summary>
 
-</head>
+        <!--Display all system informations if you want to-->
+        % if any([val != "" for _, val in result['sysinfo']['sub'].items()]):
+        <p>System informations:
+            % for name, val in result['sysinfo']['main'].items():
+            % if val != "":
+            <b>${name.upper()}</b>: ${val}
+            % endif
+            % endfor
+            <button id="btn_subinfo" class="button buttonleft toggle">
+                <svg id="btn_plus" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M0 256C0 114.6 114.6 0 256 0C397.4 0 512 114.6 512 256C512 397.4 397.4 512 256 512C114.6 512 0 397.4 0 256zM256 368C269.3 368 280 357.3 280 344V280H344C357.3 280 368 269.3 368 256C368 242.7 357.3 232 344 232H280V168C280 154.7 269.3 144 256 144C242.7 144 232 154.7 232 168V232H168C154.7 232 144 242.7 144 256C144 269.3 154.7 280 168 280H232V344C232 357.3 242.7 368 256 368z"/></svg>
+                <svg id="btn_minus" style="display: none;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M0 256C0 114.6 114.6 0 256 0C397.4 0 512 114.6 512 256C512 397.4 397.4 512 256 512C114.6 512 0 397.4 0 256zM168 232C154.7 232 144 242.7 144 256C144 269.3 154.7 280 168 280H344C357.3 280 368 269.3 368 256C368 242.7 357.3 232 344 232H168z"/></svg>
+            </button>
+            <div id="subinfo" style="display:none;transition: 0.5s;">
+                <ul>
+                    % for key_sub, val_sub in result['sysinfo']['sub'].items():
+                    % if val_sub != "":
+                    <li> <b>${key_sub}</b>: ${val_sub} </li>
+                    % endif
+                    %endfor
+                    % for key_ter, val_ter in result['sysinfo']['ter'].items():
+                    % if val_ter != "":
+                    <%
+                    val = val_ter.split()
+                    val = [''.join(l for l in item if l.isalnum() or l in [".", "_", '=']) for item in val]
+                    val = " ".join(val)
+                %>
+                    <li> <b>${key_ter}</b>: ${val} </li>
+                    % endif
+                    %endfor
+                </ul>
+            </div>
+        </p>
+        %endif
 
-<body>
-    <%
-    benchmark_name = benchmark.replace("benchmark_", "").replace("_", " ")
-    %>
-    <a id="top"></a>
-    <a href="${home}"><button class="btn"><i class="fa fa-home"></i></button></a>
-    <a href="${benchmark}.html"><button class="btn"><i class="fa fa-arrow-left"></i> ${benchmark_name}</button></a>
-    <a href="${result['fname']}"><button class="btn"><i class="fa fa-download"></i> ${result["fname_short"]}</button></a>
-    <h1 style:>Result on ${benchmark_name} benchmark</h1>
-    <summary></summary>
 
-    <!--Display all system informations if you want to-->
-    % if any([val != "" for _, val in result['sysinfo']['sub'].items()]):
-    <p>System informations:
-        % for name, val in result['sysinfo']['main'].items():
-        % if val != "":
-        <b>${name.upper()}</b>: ${val}
-        % endif
-        % endfor
-        <button id="btn_subinfo" class="button buttonleft toggle">
-            <i class='fas fa-plus-circle' id="logo_btn_info"></i>
-        </button>
-        <div id="subinfo" style="display:none;transition: 0.5s;">
-            <ul>
-                % for key_sub, val_sub in result['sysinfo']['sub'].items():
-                % if val_sub != "":
-                <li> <b>${key_sub}</b>: ${val_sub} </li>
-                % endif
+        <!-- Add selector to display only one plot -->
+        <div>
+            <label for='dataset_selector'>Dataset:</label>
+            <select id='dataset_selector' onchange="setState({dataset: this.value});">
+                % for data in result['dataset_names']:
+                <option value="${data}">${data}</option>
+                %endfor
+            </select>
+        </div>
+        <div>
+            <label for='objective_selector'>Objective:</label>
+            <select id='objective_selector' onchange="setState({objective: this.value});">
+                % for obj in result['objective_names']:
+                <option value="${obj}">${obj}</option>
                 %endfor
-                % for key_ter, val_ter in result['sysinfo']['ter'].items():
-                % if val_ter != "":
-                <%
-                val = val_ter.split()
-                val = [''.join(l for l in item if l.isalnum() or l in [".", "_", '=']) for item in val]
-                val = " ".join(val)
-            %>
-                <li> <b>${key_ter}</b>: ${val} </li>
-                % endif
+            </select>
+            <select id='objective_column' onchange="setState({objective_column: this.value});">
+                % for obj_col in result['obj_cols']:
+                <option value="${obj_col}">${obj_col.replace('objective_', '')}</option>
                 %endfor
-            </ul>
+            </select>
         </div>
-    </p>
-    %endif
-
-
-    <!-- Add selector to display only one plot -->
-    <div>
-    <label for='dataset_selector'>Dataset:</label>
-    <select id='dataset_selector' onchange="showMe(this);">
-        % for data in result['dataset_names']:
-        <option value="${data}">${data}</option>
-        %endfor
-    </select>
-    </div>
-    <div>
-    <label for='objective_selector'>Objective:</label>
-    <select id='objective_selector' onchange="showMe(this);">
-        % for obj in result['objective_names']:
-        <option value="${obj}">${obj}</option>
-        %endfor
-    </select>
-    <select id='objective_column' onchange="showMe(this);">
-        % for obj_col in result['obj_cols']:
-        <option value="${obj_col}">${obj_col.replace('objective_', '')}</option>
-        %endfor
-    </select>
-    </div>
-    <div>
-    <label for='plot_kind'>Chart type:</label>
-    <select id='plot_kind' onchange="showMe(this);">
-        % for kind in result['kinds']:
-        <option value="${kind}">${kind.replace('_', ' ')}</option>
-        %endfor
-    </select>
-    <select id="change_scaling" onchange="changeScale(this)">
-        <option value="semilog-y">semilog-y</option>
-        <option value="semilog-x">semilog-x</option>
-        <option value="loglog">loglog</option>
-        <option value="linear">linear</option>
-    </select>
-    </div>
-    <div style="display: flex; align-items: center;">
-    <p style="margin: 0; margin-right: 4px">Quantiles: </p>
-    <label for='change_shades' class="switch">
-        <input id="change_shades" type="checkbox" checked>
-        <span class="slider round"></span>
-    </label>
-    </div>
-
-    % for data in result['dataset_names']:
-    <div class="${data}">
-        % for obj in result['objective_names']:
-        <div class="${obj}">
-            % for obj_col in result['obj_cols']:
-            <div class="${obj_col}">
+        <div>
+            <label for='plot_kind'>Chart type:</label>
+            <select id='plot_kind' onchange="setState({plot_kind: this.value});">
                 % for kind in result['kinds']:
-                <div class="${kind}">
-                    <%
-        fig = result['figures'][data][obj][obj_col][kind]
-        %>
-                    % if fig.endswith('.svg'):
-                    <img src="${fig}"><br />
-                    % else:
-                    <div id="${data + obj + obj_col + kind}">
-                        ${fig}
-                    </div>
-                    %endif
-                </div>
+                <option value="${kind}">${kind.replace('_', ' ')}</option>
                 %endfor
-            </div>
-            %endfor
+            </select>
+            <select id="change_scaling" onchange="setState({scale: this.value});">
+                <option value="semilog-y">semilog-y</option>
+                <option value="semilog-x">semilog-x</option>
+                <option value="loglog">loglog</option>
+                <option value="linear">linear</option>
+            </select>
         </div>
-        %endfor
-    </div>
-    %endfor
+        <div style="display: flex; align-items: center;">
+            <p style="margin: 0; margin-right: 4px">Quantiles: </p>
+            <label for='change_shades' class="switch">
+                <input id="change_shades" type="checkbox" checked onchange="setState({with_quantiles: this.checked})">
+                <span class="slider round"></span>
+            </label>
+        </div>
+
+        <div id="unique_plot"></div>
+
+        <script src="https://cdn.plot.ly/plotly-2.12.1.min.js"></script>
+        <script>
+            window.data = ${result['json']};
+
+            window.addEventListener("load", () => {
+                // Initialize plot state
+                setState({
+                    'dataset': document.getElementById('dataset_selector').value,
+                    'objective': document.getElementById('objective_selector').value,
+                    'objective_column': document.getElementById('objective_column').value,
+                    'plot_kind': document.getElementById('plot_kind').value,
+                    'scale': document.getElementById('change_scaling').value,
+                    'with_quantiles': document.getElementById('change_shades').checked,
+                    'hidden_solvers': []
+                });
 
-    <a class="backtotop" title="Back to top" href="#top"><i class="fas fa-level-up-alt"></i></a>
+                document.getElementById('unique_plot').on('plotly_legenddoubleclick', event => {
+                    const solver = getSolverFromPlotlyEvent(event);
+                    handleSolverDoubleClick(solver);
+                });
 
-    <script type="text/javascript" src="${static_dir}/result.js"></script>
-</body>
+                document.getElementById('unique_plot').on('plotly_legendclick', event => {
+                    const solver = getSolverFromPlotlyEvent(event);
+                    handleSolverClick(solver);
+                });
+            });
+        </script>
 
-</html>
\ No newline at end of file
+        <script type="text/javascript" src="${static_dir}/result.js"></script>
+    </body>
+</html>
diff --git a/benchopt/plotting/plot_bar_chart.py b/benchopt/plotting/plot_bar_chart.py
index 654fec3..355535d 100644
--- a/benchopt/plotting/plot_bar_chart.py
+++ b/benchopt/plotting/plot_bar_chart.py
@@ -81,4 +81,43 @@ def plot_bar_chart(df, obj_col='objective_value', plotly=False):
         ax.set_ylabel("Time [sec]")
         ax.set_title(title, fontsize=12)
         fig.tight_layout()
-    return fig
+
+    return height_list
+
+
+def computeBarChartData(df, obj_col, solver):
+    """Gives the list of bar chart values.
+
+    Parameters
+    ----------
+    df : instance of pandas.DataFrame
+        The benchmark results.
+    obj_col : str
+        Column to select in the DataFrame for the plot.
+    plotly : bool
+        If set to True, creates a figure with plotly instead of matplotlib.
+
+    Returns
+    -------
+    list : bar chart values.
+    """
+    eps = 1e-6
+    c_star = df[obj_col].min() + eps
+
+    df_ = df[df['solver_name'] == solver]
+
+    # Find the first stop_val which reach a given tolerance
+    df_tol = df_.groupby('stop_val').filter(
+        lambda x: x[obj_col].max() < c_star)
+    if df_tol.empty:
+        text = 'Did not converge'
+        height = df.time.max()
+        times = np.nan
+    else:
+        stop_val = df_tol['stop_val'].min()
+        this_df = df_[df_['stop_val'] == stop_val]
+        text = ''
+        height = this_df['time'].median()
+        times = this_df['time'].tolist()
+
+    return dict(y=height, times=times, text=text)
diff --git a/benchopt/plotting/plot_objective_curve.py b/benchopt/plotting/plot_objective_curve.py
index 03728c4..05a49e4 100644
--- a/benchopt/plotting/plot_objective_curve.py
+++ b/benchopt/plotting/plot_objective_curve.py
@@ -4,7 +4,9 @@ from .helpers_compat import get_figure
 from .helpers_compat import add_h_line
 from .helpers_compat import fill_between_x
 
-CMAP = plt.get_cmap('tab10')
+CMAP = plt.get_cmap('tab20')
+
+html_solver_styles = {}
 
 
 def _remove_prefix(text, prefix):
@@ -165,3 +167,48 @@ def plot_relative_suboptimality_curve(df, obj_col='objective_value',
     """
     return plot_objective_curve(df, obj_col=obj_col, plotly=plotly,
                                 suboptimality=True, relative=True)
+
+
+def compute_quantiles(df_filtered):
+    q1 = df_filtered.groupby('stop_val')['time'].quantile(.1)
+    q9 = df_filtered.groupby('stop_val')['time'].quantile(.9)
+
+    return q1, q9
+
+
+def get_solver_color(solver):
+    if solver in html_solver_styles and 'color' in html_solver_styles[solver]:
+        return html_solver_styles[solver]['color']
+
+    idx = len(html_solver_styles)
+    color = CMAP(idx % CMAP.N)
+    color = tuple(255*x if i != 3 else x for i, x in enumerate(color))
+    color = f'rgba{color}'
+
+    if solver in html_solver_styles:
+        html_solver_styles[solver]['color'] = color
+    else:
+        html_solver_styles[solver] = {
+            'color': color
+        }
+
+    return color
+
+
+def get_solver_marker(solver):
+    if solver in html_solver_styles and 'marker' in html_solver_styles[solver]:
+        return html_solver_styles[solver]['marker']
+
+    markers = {i: i for i, v in enumerate(plt.Line2D.markers)}
+
+    i = len(html_solver_styles)
+    marker = markers[i % len(markers)]
+
+    if solver in html_solver_styles:
+        html_solver_styles[solver]['marker'] = marker
+    else:
+        html_solver_styles[solver] = {
+            'marker': marker
+        }
+
+    return marker
diff --git a/benchopt/runner.py b/benchopt/runner.py
index e0837ba..2b19813 100644
--- a/benchopt/runner.py
+++ b/benchopt/runner.py
@@ -7,19 +7,15 @@ from joblib import Parallel, delayed
 from .callback import _Callback
 from .benchmark import _check_name_lists
 from .utils.sys_info import get_sys_info
-from .utils.pdb_helpers import exception_handler
 from .utils.files import uniquify_results
-
+from .utils.pdb_helpers import exception_handler
 from .utils.terminal_output import TerminalOutput
 
-# For compat with the lasso benchmark, expose INFINITY in this module.
-# Should be removed once benchopt/benchmark_lasso#55 is merged
-from .stopping_criterion import INFINITY  # noqa: F401
-
-
 ##################################
 # Time one run of a solver
 ##################################
+
+
 def run_one_resolution(objective, solver, meta, stop_val):
     """Run one resolution of the solver.
 
diff --git a/benchopt/utils/conda_env_cmd.py b/benchopt/utils/conda_env_cmd.py
index dcb6a0d..f1aed94 100644
--- a/benchopt/utils/conda_env_cmd.py
+++ b/benchopt/utils/conda_env_cmd.py
@@ -155,11 +155,13 @@ def get_benchopt_version_in_env(env_name):
         "benchopt --version --check-editable",
         env_name=env_name, capture_stdout=True, return_output=True
     )
+
     if check_benchopt != 0:
         if DEBUG:
             print(output)
         return None, None
     benchopt_version, is_editable = output.split()
+
     return benchopt_version, is_editable == 'True'
 
 
diff --git a/benchopt/utils/files.py b/benchopt/utils/files.py
index 99cb86e..da014a7 100644
--- a/benchopt/utils/files.py
+++ b/benchopt/utils/files.py
@@ -1,5 +1,5 @@
-from pathlib import Path
 import warnings
+from pathlib import Path
 
 
 def rm_folder(folder):
@@ -14,7 +14,7 @@ def rm_folder(folder):
 
 
 def uniquify_results(file_path):
-    "Add a number to filename if it already exists"
+    "Add a number to filename if it already exists."
     if file_path.exists():
         parent = file_path.parent
         stem = file_path.stem
@@ -25,7 +25,7 @@ def uniquify_results(file_path):
         alternative = parent / f"{stem}_{i}{suffix}"
         warnings.warn(
             f"{file_path} already exists. Saving results to {alternative}"
-            )
+        )
         return alternative
     else:
         return file_path
diff --git a/benchopt/utils/misc.py b/benchopt/utils/misc.py
index d63a00d..9c218bc 100644
--- a/benchopt/utils/misc.py
+++ b/benchopt/utils/misc.py
@@ -3,7 +3,7 @@
 def get_benchopt_requirement():
     """Specification for pip requirement to install benchopt in conda env.
 
-    Find out how benchopt where installed so we can install the same version
+    Find out how benchopt was installed so we can install the same version
     even if it was installed in develop mode. This requires pip version >= 20.1
 
     Returns
diff --git a/benchopt/utils/sys_info.py b/benchopt/utils/sys_info.py
index e59f4dc..fb0202a 100644
--- a/benchopt/utils/sys_info.py
+++ b/benchopt/utils/sys_info.py
@@ -8,6 +8,9 @@ import subprocess
 from shutil import which
 from pathlib import Path
 
+from ..config import DEBUG
+from .shell_cmd import _run_shell
+
 
 def _get_processor_name():
     "Return processor name in a cross-platform way."
@@ -45,7 +48,7 @@ def get_cuda_version():
     except AttributeError:
         warnings.warn(
             "Could not parse cuda version or device name from `nvidia-smi`."
-         )
+        )
         return None
 
 
@@ -75,6 +78,15 @@ def _get_numpy_libs():
     return libs
 
 
+def _get_git_tag():
+    err, tag = _run_shell("git describe --tags --abbrev=0", return_output=True)
+    if err != 0:
+        if DEBUG:
+            print(err, tag)
+        tag = None
+    return tag
+
+
 def get_sys_info():
     "Return a dictionary with info from the current system."
 
@@ -107,4 +119,7 @@ def get_sys_info():
     info["version-numpy"] = (np.__version__, _get_numpy_libs())
     info["version-scipy"] = scipy.__version__
 
+    # Info on benchmark version
+    info["benchmark-git-tag"] = _get_git_tag()
+
     return info
diff --git a/doc/whats_new.rst b/doc/whats_new.rst
index d62ba93..40ae83f 100644
--- a/doc/whats_new.rst
+++ b/doc/whats_new.rst
@@ -21,11 +21,18 @@ CLI
 - Add ``--slurm`` option in ``benchopt run`` to allow running the benchmark on
   a SLURM cluster. See the :ref:`slurm_run` for more details on the config.
   By `Thomas Moreau`_ (:gh:`407`)
-  
+
 - Add ``benchopt archive`` to create a ``tar.gz`` archive with the benchmark's
   files for sharing with others or as supplementary materials for papers.
   By `Thomas Moreau`_ (:gh:`408`).
 
+
+API
+~~~
+
+- Add latest git tag via ``benchmark-git-tag`` key in :func:`benchopt.utils.sys_info.get_sys_info`.
+  By `Mathurin Massias`_ (:gh:`421`).
+
 .. _changes_1_2:
 
 Version 1.2 - 06/05/2022


Test patch:
diff --git a/benchopt/tests/test_cli.py b/benchopt/tests/test_cli.py
index bbdf497..62ab5a8 100644
--- a/benchopt/tests/test_cli.py
+++ b/benchopt/tests/test_cli.py
@@ -429,15 +429,20 @@ class TestPlotCmd:
     def test_plot_invalid_file(self):
 
         with pytest.raises(FileNotFoundError, match=r"invalid_file"):
-            plot([str(DUMMY_BENCHMARK_PATH), '-f', 'invalid_file'],
-                 'benchopt', standalone_mode=False)
+            plot([str(DUMMY_BENCHMARK_PATH), '-f', 'invalid_file', '--no-html',
+                  '--no-display'], 'benchopt', standalone_mode=False)
 
     def test_plot_invalid_kind(self):
 
         with pytest.raises(ValueError, match=r"invalid_kind"):
+            plot([str(DUMMY_BENCHMARK_PATH), '-k', 'invalid_kind', '--no-html',
+                  '--no-display'], 'benchopt', standalone_mode=False)
 
-            plot([str(DUMMY_BENCHMARK_PATH), '-k', 'invalid_kind'],
-                 'benchopt', standalone_mode=False)
+    def test_plot_html_ignore_kind(self):
+
+        with pytest.warns(UserWarning, match=r"Cannot specify '--kind'"):
+            plot([str(DUMMY_BENCHMARK_PATH), '-k', 'invalid_kind', '--html',
+                  '--no-display'], 'benchopt', standalone_mode=False)
 
     @pytest.mark.parametrize('kind', PLOT_KINDS)
     def test_valid_call(self, kind):