A R graphic in a Yesod app
Yesod is a web framework for Haskell. In this post I show how to do a Yesod application allowing to upload some data from a CSV or a XLSX file and to display a R graphic representing two selected columns of the data.
Below is the directory content of the application, available in this Github repository.
| .dir-locals.el
| client_session_key.aes
| package.yaml
| README.md
| routes.yesodroutes
| stack.yaml
|
+---app
| Main.hs
|
+---src
| Application.hs
| Foundation.hs
| GGplot.hs
| Home.hs
|
\---static
+---bootstrap-5.3.2
| +---css
| | bootstrap.min.css
| | bootstrap.min.css.map
| |
| \---js
| bootstrap.bundle.min.js
| bootstrap.bundle.min.js.map
|
+---DataTables-1.13.8
| | datatables.min.css
| | datatables.min.js
| |
| \---images
| sort_asc.png
| sort_asc_disabled.png
| sort_both.png
| sort_desc.png
| sort_desc_disabled.png
|
+---images
| haskell.png
|
+---jQuery
| jquery-3.7.1.min.js
|
+---PapaParse
| papaparse.min.js
|
+---R
| ggplotXY.R
|
\---SheetJS
xlsx.core.min.js
xlsx.core.min.map
I’m using bootstrap for the style, DataTables to display a nice table of the data, PapaParse to parse the uploaded CSV file to a JSON object, and SheetJS to convert the uploaded XLSX file to CSV data, which can then be parsed to a JSON object with PapaParse.
Hamlet is a HTML templating language developed for Yesod applications. Below is the Hamlet code of the application. I use a Bootstrap modal to display errors if there are.
<body>
$# BOOTSTRAP MODAL -----------------------------------------------------------<div #myModal .modal .fade aria-hidden aria-labelledby=myModalLabel tabindex=-1>
<div .modal-dialog .modal-dialog-centered>
<div .modal-content>
<div .modal-header>
<h1 .modal-title .fs-5>
<div .modal-body>
<span #message>
<div .modal-footer>
<button type=button .btn .btn-secondary data-bs-dismiss=modal>Close
$# HASKELL LOGO --------------------------------------------------------------<img src=./static/images/haskell.png style=float:right;margin:5px;width:50px;>
$# ---------------------------------------------------------------------------<div .container-fluid>
$# TABS --------------------------------------------------------<ul .nav .nav-tabs role=tablist>
<li .nav-item role=presentation>
<button #data-tab .nav-link .active data-bs-toggle=tab data-bs-target=#data-tab-pane type=button role=tab aria-controls=data-tab-pane aria-selected=true>Data
<li .nav-item role=presentation>
<button #plot-tab .nav-link data-bs-toggle=tab data-bs-target=#plot-tab-pane type=button role=tab aria-controls=plot-tab-pane aria-selected=false>Plot
$# TABS CONTENTS -----------------------------------------------------------<div #tabContent .tab-content>
$# DATA TAB --------------------------------------------------------------<div #data-tab-pane .tab-pane .fade .show .active role=tabpanel aria-labelledby=data-tab tabindex=0>
<div .row>
$# SIDEBAR -----------------------------------------------------------<div .col-4>
<div .card .text-bg-dark tabindex=-1 aria-labelledby=sidebarDataTitle>
<div .card-body>
<h5 #sidebarDataTitle .card-title>Upload data
<h6 .card-text>Upload a CSV file or a XLSX file.
<p .card-text style=font-style:italic;>If you upload a XLSX file, the data from the first sheet will be extracted.
<input #file type=file .form-control .btn .btn-info>
$# TABLE -------------------------------------------------------------<div .col-8>
<table #table .table-striped .table-bordered .table-hover>
<thead>
<tr role=row>
<tbody>
$# PLOT TAB --------------------------------------------------------------<div #plot-tab-pane .tab-pane .fade role=tabpanel aria-labelledby=plot-tab tabindex=0>
<div .row>
$# SIDEBAR -----------------------------------------------------------<div .col-4>
<div .sidebar .card .text-bg-dark tabindex=-1 aria-labelledby=sidebarPlotTitle>
<div .card-body>
<div .sidebar-header>
<h5 #sidebarPlotTitle .card-title>Plot
<div .sidebar-body>
<fieldset #selectXY style=display:none;>
<label for=selX>Select the <em>x</em> column
<select .form-control #selX style=overflow-y:auto;>
<br>
<label for=selY>Select the <em>y</em> column
<select .form-control #selY style=overflow-y:auto;>
$# SPINNER ---------------------------------------------------------<div #spinner .spinner-border .m-5 role=status style=display:none>
<span .visually-hidden>Loading...
$# PLOT --------------------------------------------------------------<div .col-8>
<img #plot width=100% height=400px>
The interface has two tabs: one to upload and display the data, and the other one to select two columns and display the graphic.
The JavaScript function below will be called when the user uploads a file. The file can be either a CSV file or a XLSX file. If this is a XLSX file, then its content will be converted to CSV data before calling this function. This function firstly converts the CSV data to a JSON object, then it fills the table with the data and the x
and y
dropdown lists with the column names, and then it defines the behavior of the application.
function papaParse(csv) {
Papa.parse(csv, {
header: true,
skipEmptyLines: true,
dynamicTyping: true,
complete: function(results) {
if(results.errors.length != 0) {
alert("Something is wrong with this CSV file.");
console.log("Errors:", results.errors);
throw new Error("Something is wrong with this CSV file.");
}
let dataframe = results.data;
let colNames = results.meta.fields;
// Fill the table --------------------------------------------------------
let headers = "";
for(let colname of colNames) {
headers += "<th>" + colname + "</th>";
}
$("#table thead tr").append(headers);
let columns = [];
for(let colname of colNames) {
columns.push({ data: colname });
}
$("#table").DataTable({
data: results.data,
columns: columns
});
// the dataframe is an array of objects like:
// [{A: a1, B: b1, ...}, {A: a2, B: b2, ...}, ...]
// we transform it to this object:
// {A: [a1, a2, ...], B: [b1, b2, ...], ...}
let dfx = {}; // for x, we convert every entry to a string
let dfy = {}; // for y, we don't convert anything
for(let colname of colNames) {
let columnx = [];
let columny = [];
for(let j = 0; j < dataframe.length; j++) {
let entry = dataframe[j][colname];
columnx.push(entry.toString());
columny.push(entry);
}
dfx[colname] = columnx;
dfy[colname] = columny;
}
// Fill the x & y dropdowns ----------------------------------------------
let $selsXY = $("#selX, #selY");
let ncolumns = colNames.length;
let size = ncolumns < 5 ? ncolumns : 5;
$selsXY.attr("size", size);
$(colNames).each(function(idx, item) {
if(item != "") {
$selsXY.append($("<option>").attr("value", idx).text(item));
}
});
// Set x to the first column and y to the second one ---------------------
let selX = document.querySelector("#selX");
let selY = document.querySelector("#selY");
selX.value = "0";
selY.value = "1";
$("#selectXY").show();
// Initial plot ----------------------------------------------------------
let myModalEl = document.getElementById("myModal");
let myModal = new bootstrap.Modal(myModalEl);
let messageEl = myModalEl.querySelector("#message");
let titleEl = myModalEl.querySelector(".modal-title");
let $selX = $("#selX");
let $selY = $("#selY");
plot($selX, $selY, dfx, dfy, colNames, titleEl, messageEl, myModal);
// Plot on change x or y -------------------------------------------------
$selsXY.on("change", function() {
plot($selX, $selY, dfx, dfy, colNames, titleEl, messageEl, myModal);
});
// Plot on resize --------------------------------------------------------
$(window).on("resize", function() {
plot($selX, $selY, dfx, dfy, colNames, titleEl, messageEl, myModal);
});
}
});
}
Here is the JavaScript code handling the file upload:
$(function() {
$("#file").on("change", function(e) {
let file = e.target.files[0];
let extension = file.name.split(".").pop().toLowerCase();
// --------------------------------------------------------------------
if(extension === "xlsx") {
let reader = new FileReader();
reader.onload = function (e) {
let workbook;
try {
workbook = XLSX.read(e.target.result, {
type: "binary"
});
} catch(err) {
alert("Something is wrong with this XLSX file.");
throw new Error(err);
}
let sheetNames = workbook.SheetNames;
let sheet1 = sheetNames[0];
let XLSXasCSV = XLSX.utils.sheet_to_csv(workbook.Sheets[sheet1]);
papaParse(XLSXasCSV);
};
reader.onerror = function(err) {
alert("I can't read this XLSX file!");
throw new Error(err);
};
reader.readAsArrayBuffer(file);
} else if(extension === "csv" || extension === "tsv") {
papaParse(file);
}
});
});
Below is the plot
function. It firstly collects the data of the two selected columns and the dimensions of the plot container, and then with an Ajax PUT request, it sends all these data to Haskell. The Haskell function putGgplotR
will receive these data, it will send them to R and it will get the result from R. This result is either a base64 string coding the graphic or an error message. We use a separator "*::*::*::*::*"
to put the error message at the left of it and the base64 string at the right of it. If there’s no error then the left part is the empty string. The Ajax request receives this result. If there is an error message then it includes it in the Bootstrap modal and displays this modal. If there is no error message then it sends the base64 string to the img
element of the interface.
function plot($selX, $selY, dfx, dfy, colNames, titleEl, messageEl, myModal) {
$("#spinner").show();
let xidx = $selX.val();
let yidx = $selY.val();
let x = dfx[colNames[xidx]];
let y = dfy[colNames[yidx]];
let width = $("#plot").width();
if(width === 0) {
// the plot tab is initially hidden and then width=0
width = 770;
}
let height = $("#plot").height();
if(height === 0) {
height = 400;
}
let XYwh = JSON.stringify({ _x: x, _y: y, _width: width, _height: height });
let JSONstring = JSON.stringify(XYwh);
$.ajax({
contentType: "application/json; charset=UTF-8",
processData: false,
url: "@{GgplotR}",
type: "PUT",
data: JSONstring,
success: function(string) {
$("#spinner").hide();
let error_base64 = string.split("*::*::*::*::*");
let error = error_base64[0];
if(error === "") {
let base64 = error_base64[1];
$("#plot").attr("src", base64);
} else {
titleEl.textContent = "An error has occured";
messageEl.textContent = error;
myModal.show();
}
},
dataType: "text"
});
}
The Haskell function putGgplotR
that we just mentioned will send the data to R with a JSON file written in the temporary folder. Here is the function used to write a temporary file:
writeTempFile :: String -> FilePath -> IO FilePath
= do
writeTempFile contents fileName <- getCanonicalTemporaryDirectory
tmpDir <- createTempDirectory tmpDir "yesod"
dir let filePath = dir ++ "/" ++ fileName
writeFile filePath contents
return $ replaceBackslahes filePath
where
replaceBackslahes :: String -> String
= subRegex (mkRegex "\\\\") string "/" replaceBackslahes string
And here is the function putGgplotR
:
putGgplotR :: Handler String
= do
putGgplotR <- requireCheckJsonBody :: Handler String
jsonData <- liftIO $ writeTempFile jsonData "data.json"
jsonFile <- liftIO $
(exitcode, stdout, stderr) "Rscript" ["-e", rCommand jsonFile] ""
readProcessWithExitCode let base64 = stdout
let err = if exitcode == ExitSuccess then "" else stderr
-- return the error message and the base64 string with a separator
return $ err ++ "*::*::*::*::*" ++ base64
where
rCommand :: FilePath -> String
=
rCommand file "jsonFile<-" ++ quote file ++
";source(" ++ quote "static/R/ggplotXY.R" ++ ")"
where
quote :: String -> String
= "\"" ++ x ++ "\"" quote x
Finally, here is the R file ggplotXY.R which is sourced:
library(ggplot2)
library(jsonlite)
library(base64enc)
# extract data from the JSON file
jsonData <- fromJSON(jsonFile)
x <- jsonData[["_x"]]
y <- jsonData[["_y"]]
w <- jsonData[["_width"]]
h <- jsonData[["_height"]]
# if `y` is not numeric, we throw an error
if(!is.numeric(y)) {
stop("The `y` column is not numeric.")
}
# function to convert x to numeric if possible
maybeNumeric <- function(x) {
xx <- as.numeric(x)
if(anyNA(xx)) x else xx
}
# data
dat <- data.frame(x = maybeNumeric(x), y = y)
# plot
gg <- ggplot(dat, aes(x = x, y = y)) +
geom_point()
# save plot as PNG
png <- tempfile(fileext = ".png")
ggsave(png, gg, width = w, height = h, units = "px", dpi = "print")
# convert the PNG file to a base64 string
base64 <- dataURI(file = png, mime = "image/png")
# print the base64 string
cat(base64)