Well Monitoring App: Logic and Workflow

Technical documentation of the web application (D3 + Leaflet)

Published

October 28, 2026

Overview

The Well Monitoring App is a browser-only application for exploring the water chemistry of a set of wells over time. It runs entirely on the client: there is no R or Shiny server behind it. R is used once, offline, to prepare the data; the interactive part is written in JavaScript with D3 (charts) and Leaflet (map).

The application allows the user to:

  • Locate the wells on a satellite map, switching between two types of water: produced water (formation brine) and groundwater from monitoring wells,
  • Open a well page with the latest values, the change from the previous sample and one chart per variable (time series or boxplot, linear or log scale),
  • Compare wells of the same type, one family of variables at a time,
  • Check every monitoring well against regulatory guideline levels with a traffic-light status,
  • Generate a monitoring report for any period, print it to PDF or download the results as CSV,
  • Open a sample sheet with every result measured in a well on a given day.

All data shown are synthetic. Wells are named Pozo 1, Pozo 2, … and their positions on the map are fictitious.


Architecture

R (offline)                                    Browser
───────────                                    ───────
script.R          read + clean lab results
config.R          variable families, guideline levels, well types
exportar_datos.R  build the data object  ─┐
sintetizar_datos.R synthetic values      ─┴──► app/datos.js  ──►  app.js (D3 + Leaflet)
empaquetar_app.R  bundle everything ─────────► single HTML file
File Role
app/index.html Page skeleton: map view, well view, comparison view, report view and sample sheet
app/styles.css Layout and visual style
app/app.js All the application logic
app/datos.js The data, as a script that sets window.DATOS

The data are written as a script (window.DATOS = {...}) instead of a JSON file on purpose: browsers block fetch() on local files, so a script tag lets index.html work when opened directly from disk, without a local server.

Libraries

<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js"></script>
<script src="datos.js"></script>
<script src="app.js"></script>

No build step and no framework: the whole app is about 1,100 lines of plain JavaScript.


Data preparation (R)

Cleaning (script.R)

The laboratory export is read and cleaned in a few steps:

  1. Censored results. Results reported as not detected (nd) or below the quantification limit (<) are replaced by the corresponding limit, and the original flag is kept so the app can show them as < x.
  2. Well and date. The well number and the sampling date are parsed from the sample label with regular expressions. When the label has no date, the earliest confirmation date of the sample is used.
  3. Parameter names. The analysis code is removed so that the same parameter reported under different codes is merged into one.
  4. Units. Parameters reported in more than one mass unit are converted to mg/l, and multipliers such as x10³ NMP/100 ml are expanded.
Show code
datos <- datos %>%
  mutate(Parámetro = Análisis %>%
           str_remove("^[^-]+-") %>%      # drop the analysis code
           str_remove("\\.$") %>%
           str_squish(),
         Unidad = str_squish(`Unidad inicial`)) %>%
  group_by(Parámetro) %>%
  mutate(a_mg_l = n_distinct(Unidad) > 1 & all(Unidad %in% names(factor_a_mg_l))) %>%
  ungroup() %>%
  mutate(Resultado = if_else(a_mg_l, Resultado * factor_a_mg_l[Unidad], Resultado),
         Unidad    = if_else(a_mg_l, "mg/l", Unidad))

Core parameters and families (config.R)

The app focuses on a fixed list of core parameters (those measured in most samples) plus every parameter that has a guideline level. They are grouped into families, and comparisons between wells always happen within one family:

Family Examples
Major ions and salinity chloride, sulfate, calcium, sodium, total dissolved solids
Metals arsenic, lead, cadmium, barium, iron (total and dissolved)
Nutrients and other inorganics ammonium, nitrate, nitrite, fluoride, cyanide
BTEX benzene, toluene, ethylbenzene, xylenes
PAH naphthalene, phenanthrene, pyrene, benzo(a)pyrene, sum of 16 EPA PAH
Hydrocarbons and other organics diesel and gasoline range organics, phenols, anionic detergents

Every other parameter is still exported, but only for the sample sheet; it is assigned to a family by matching its name against a list of patterns.

Guideline levels

Monitoring wells are compared with the levels of Decree 831/93, Annex II, Table 1 (drinking-water source with conventional treatment). For parameters that table does not cover (chloride, sulfate, dissolved solids and detergents), the Argentine Food Code, art. 982 is used. Each level is stored in the same unit as the data, and exportar_datos.R checks it:

Show code
ng <- niveles_guia %>% left_join(parametros, by = "Parámetro")
stopifnot(!anyNA(ng$Unidad), ng$unidad == ng$Unidad)   # same unit as the data

The table sets a single value for “polynuclear aromatic hydrocarbons” without defining which compounds it includes. The app uses the sum of the 16 EPA priority PAH, counting non-detects as zero, and only for samples where all 16 were measured.

Export format (exportar_datos.R)

Results are exported column-wise (one array per field instead of one object per result), which makes the file much smaller:

Show code
registros <- datos_app_base %>%
  transmute(pozo  = Pozo,
            fecha = format(Fecha, "%Y-%m-%d"),
            param = match(Parámetro, parametros$Parámetro) - 1,  # 0-based index
            valor = signif(Resultado, 6),
            cens  = as.integer(!is.na(`Modificador de resultado`)))
Field of window.DATOS Contents
pozos id, latitude, longitude, type (produccion / monitoreo) and colour of each well
familias, parametros Families and core parameters (name, unit, family)
niveles_guia Guideline value and source for each regulated parameter
monitoreo Alert threshold (fraction of the guideline level)
registros Core results: well, date, parameter index, value, censored flag
extra Parameters and results shown only in the sample sheet

Synthetic data (sintetizar_datos.R)

The published version uses sintetizar_datos.R instead of exportar_datos.R. It builds the same object and then replaces the well identifiers, places the wells at random positions in another region and replaces every quantified value with a synthetic one. Results reported as nd or < keep their detection limit.

Single-file bundle (empaquetar_app.R)

empaquetar_app.R inlines the stylesheet, the data and the code into one HTML file, so the app can be shared as a single attachment:

Show code
reemplazos <- c(
  '<link rel="stylesheet" href="styles.css">' = str_c("<style>\n", leer("styles.css"), "\n</style>"),
  '<script src="datos.js"></script>'          = str_c("<script>\n", codigo$datos, "\n</script>"),
  '<script src="app.js"></script>'            = str_c("<script>\n", codigo$app, "\n</script>")
)

It first checks that no </script string appears inside the code, since that would close the tag early.


Application logic (app.js)

Loading the data

The column-wise arrays are turned back into objects once, and indexed with d3.group so that every view can get the results of a well and a parameter directly:

const registros = R.pozo.map((pozo, i) => ({
  pozo, fecha: parseFecha(R.fecha[i]), param: R.param[i], valor: R.valor[i], cens: R.cens[i] === 1,
}));
// well → parameter → results
const indice = d3.group(registros, d => d.pozo, d => d.param);
const datosDe = (pozo, param) => indice.get(pozo)?.get(param) ?? [];

Replicates

A well can have several results for the same parameter on the same day (replicates). Charts use one value per date: the median, which is censored only if every replicate is censored.

function porFecha(regs) {
  return d3.rollups(regs, v => ({
    fecha: v[0].fecha, valor: d3.median(v, d => d.valor), cens: v.every(d => d.cens),
  }), d => +d.fecha).map(([, v]) => v).sort((a, b) => a.fecha - b.fecha);
}

Two types of water

Produced water and groundwater differ by orders of magnitude and answer different questions, so they are never compared with each other. The map has a selector for the two modes, and each one keeps its own selection of wells and variables:

Mode Wells shown Default variables Guideline levels
Produced water production wells chloride, total barium, sulfate no
Monitoring monitoring wells chloride, dissolved solids, benzene yes

Traffic-light status

Each measurement of a regulated parameter is classified against its guideline level (NG):

Status Rule
exceeds value > NG
alert value ≥ 50 % of NG
LQ > NG not detected, but the detection limit is already above NG (inconclusive)
below NG otherwise
const nivelDe = (d, ng) => (d.cens ? (d.valor > ng.valor ? "nc" : "ok")
  : d.valor > ng.valor ? "supera"
  : d.valor >= FRACCION_ALERTA * ng.valor ? "alerta" : "ok");

The status of a well is the worst status among the variables measured in its latest sampling. Variables last measured on an earlier date are listed separately, so an old result does not decide the current status.


Views

Map

A Leaflet map with satellite imagery. Each well is a coloured marker with a label; in monitoring mode the label also carries the well’s traffic light, and a side card summarises which variables exceed their guideline level in each well. Clicking a well flies the map to it and opens the well view.

Well view

  • A table with the latest value of each regulated variable, its guideline level, the percentage of the level and how many past samples exceeded it (monitoring wells).
  • Cards with the latest value and the change from the previous sample for the selected variables.
  • One chart per variable, either a time series or a boxplot per year, with an optional log scale.

Comparison view

The user picks the wells, a family of variables and the variables inside it. Each variable gets a chart with one colour per well, and a table shows the latest value of each well side by side.

Charts

All charts are drawn with D3 from the same function. Some details:

  • Filled points are quantified values; hollow points are non-detects plotted at their limit.
  • The line joins the median per date of the quantified values only (joining detection limits would create fake peaks), and it is broken when more than a year passes between samples.
  • The guideline level is drawn as a horizontal line on monitoring wells.
  • On a log scale, ticks are powers of ten when there are at least two of them.
  • Hovering a chart shows a tooltip and highlights the same date in every chart; clicking a point opens the sample sheet for that date.
// Median per date of the quantified values, split where the gap is longer than a year.
function tramos(regs) {
  const f = porFecha(regs.filter(d => !d.cens));
  const out = [];
  f.forEach((d, i) => {
    if (i === 0 || d.fecha - f[i - 1].fecha > UN_ANIO) out.push([]);
    out.at(-1).push(d);
  });
  return out;
}

Sample sheet

A side panel with every parameter measured in a well on one day, grouped by family: first the core families, then the extra ones. Values above a guideline level are flagged. Arrow buttons (or the keyboard arrows) move to the previous or next sample of the same well.

Monitoring report

A printable report of the monitoring wells for a chosen period (each calendar year or the whole history, or custom dates):

  1. A summary per well: number of samplings, worst status and number of exceedances.
  2. The list of every exceedance (well, date, variable, value, guideline level).
  3. One results table per well: dates in columns, variables in rows. Long periods are split into several tables so they fit on a page.
  4. Optionally, charts of the variables that exceeded their level.

The report can be printed or saved as PDF (with a print stylesheet), or the results can be downloaded as CSV, using semicolons and decimal commas so the file opens directly in Spanish-locale Excel.


Design observations

Strengths

  • No server. The app is three static files plus the data; it can be hosted anywhere or even sent as a single HTML file.
  • Small data file. The column-wise format and parameter indices keep the data compact, and it is parsed only once.
  • Honest handling of non-detects. Censored values are always shown as < x, are excluded from trend lines and can make a status inconclusive instead of passing silently.
  • Separate modes. Keeping produced water and groundwater apart avoids charts dominated by values that are orders of magnitude larger.
  • Checks in the R pipeline. stopifnot() calls stop the export if families, units or guideline levels do not match the data.

Opportunities for improvement

  • The guideline levels and the alert threshold are fixed in config.R; letting the user switch between regulations would make the app reusable in other contexts.
  • app.js is a single file; splitting it into modules (data, map, charts, report) would make it easier to maintain.