appDatosReact: Logic and Workflow

Technical documentation of the React application

Published

February 21, 2026

Overview

appDatosReact is a React rebuild of the original Shiny app (appDatos), designed for the interactive exploration of environmental and food safety laboratory data. It preserves all features of the Shiny version while adding a faster, client-side architecture with no R server required.

The user can:

  • Select a matrix (type of product or sample),
  • Choose analytes for the X and Y axes via a searchable combobox,
  • Filter by entity using a multi-select checkbox list,
  • Explore data through a scatter plot (two variables) or a histogram (one variable), both rendered with D3,
  • Consult a Spearman correlation table for the selected X-axis analyte.

Technology stack

Layer Technology
Framework React 19
Build tool Vite 7
Styling Tailwind CSS
UI components shadcn/ui (Card, Tabs, Select, Checkbox, Input, Button, Popover, Command)
Charting D3 v7 (custom SVG rendering)
Data format JSON files served as static assets from /public/data/
Deployment Hostinger static hosting at yoursite.com/app/

Project structure

appDatosReact/
├── public/
│   └── data/
│       ├── agupr.json          ← Analytical data per matrix
│       ├── correl_agupr.json   ← Spearman correlations per matrix
│       └── ...                 (one pair per matrix)
├── src/
│   ├── App.jsx                 ← Root component: state, data loading, layout
│   └── components/
│       ├── Sidebar.jsx         ← Controls panel (filters, log scales, reference lines)
│       ├── AxisCombobox.jsx    ← Searchable dropdown for axis variable selection
│       ├── CorrelationTable.jsx← Correlation table (pure display component)
│       └── charts/
│           ├── Scatterplot.jsx ← D3 scatter plot
│           └── Histogram.jsx   ← D3 histogram
├── vite.config.js              ← base: "/app/" for subdirectory deployment
└── .htaccess                   ← Manually placed in dist/ for SPA routing

Data files

Data are stored as static JSON files in public/data/. There are two files per matrix:

File pattern Contents
<key>.json Array of row objects — one per sample. Fields: analyte columns + Entidad, Fracción
correl_<key>.json Array of objects with Analisis + one column per analyte (Spearman r values)

The mapping from matrix name to file key is defined in App.jsx as a plain object:

const DATA_FILES = {
  "AGUA PROCESO":    "agupr",
  "AGUA SUPERFICIAL": "super_agua",
  // ... 30 matrices total
}

Analyte names are not stored separately — they are inferred at load time by filtering out the metadata columns (Entidad, Fracción) from the first row of the data:

const cols = Object.keys(datos[0] || {})
  .filter(k => !["Rótulo", "Entidad", "Fracción"].includes(k))
setAnalisis(cols)

Application state (App.jsx)

All global state lives in App.jsx via useState. There is no external state manager (no Redux, no Context).

State variable Type Description
activeTab "dos" | "una" Which tab is visible
matrix string Currently selected matrix name
data array Raw data rows for the selected matrix
correl array Correlation rows for the selected matrix
analisis string[] Analyte column names derived from data
xVar string Selected X-axis analyte
yVar string Selected Y-axis analyte
controls object All sidebar controls (see below)
selectedEntity string | null Entity highlighted by clicking the chart legend
allEntities string[] All unique entities in the loaded data
selectedEntities string[] Entities currently checked in the sidebar list

The controls object

A single state object holds all sidebar inputs, initialized to DEFAULT_CONTROLS:

const DEFAULT_CONTROLS = {
  log: false,       // apply log10() to data values on both axes (scatter only)
  logx: false,      // log10 axis scale on X
  logy: false,      // log10 axis scale on Y (scatter only)
  vert: 0,          // vertical reference line 1
  vert2: 0,         // vertical reference line 2
  hor: 0,           // horizontal reference line 1 (scatter only)
  hor2: 0,          // horizontal reference line 2 (scatter only)
  pendiente: null,  // slope of manual line 1 (scatter only)
  ordenada: null,   // intercept of manual line 1
  pendiente2: null, // slope of manual line 2
  ordenada2: null,  // intercept of manual line 2
  ent: false,       // show entity coloring and legend
}

All controls are updated through a single handler:

const handleControlChange = (key, value) => {
  setControls(c => ({ ...c, [key]: value }))
  if (key === "ent" && !value) setSelectedEntity(null)
}

Data loading (useEffect)

Data loads whenever matrix changes. Both JSON files (data + correlations) are fetched in parallel with Promise.all:

useEffect(() => {
  const file = DATA_FILES[matrix]
  if (!file) return

  Promise.all([
    fetch(`${import.meta.env.BASE_URL}data/${file}.json`).then(r => r.json()),
    fetch(`${import.meta.env.BASE_URL}data/correl_${file}.json`).then(r => r.json()),
  ]).then(([datos, correl]) => {
    setData(datos)
    setCorrel(correl)
    const cols = Object.keys(datos[0] || {})
      .filter(k => !["Rótulo", "Entidad", "Fracción"].includes(k))
    setAnalisis(cols)
    setXVar(cols[0] ?? "")
    setYVar(cols[1] ?? "")
    setSelectedEntity(null)
    const entities = [...new Set(datos.map(d => d.Entidad).filter(Boolean))]
      .sort((a, b) => Number(b) - Number(a))
    setAllEntities(entities)
    setSelectedEntities(entities)  // all selected by default
  })
}, [matrix])

import.meta.env.BASE_URL resolves to /app/ in production (set in vite.config.js), so data files are served from yoursite.com/app/data/.


Derived state

Two values are derived from state without being stored themselves:

filteredData (inline computation)

Computed on every render. Filters data based on the entity selection:

const filteredData = data.filter(d =>
  !controls.ent || selectedEntities.includes(d.Entidad)
)

When controls.ent is false (entity mode off), all rows pass through. When it is true, only rows whose Entidad is in selectedEntities are kept.

validEntities (useMemo)

Computes which entities actually have valid (non-null, and positive if log-transformed) data for the current axes and tab. This list is passed to Sidebar to show only meaningful entities in the checkbox list and chart legend:

const validEntities = useMemo(() => {
  if (!xVar) return allEntities
  if (activeTab === "dos") {
    return allEntities.filter(ent =>
      data.some(d =>
        d.Entidad === ent &&
        d[xVar] != null && d[yVar] != null &&
        (!controls.log  || (d[xVar] > 0 && d[yVar] > 0)) &&
        (!controls.logx || d[xVar] > 0) &&
        (!controls.logy || d[yVar] > 0)
      )
    )
  } else {
    return allEntities.filter(ent =>
      data.some(d => d.Entidad === ent && d[xVar] != null && (!controls.logx || d[xVar] > 0))
    )
  }
}, [allEntities, data, xVar, yVar, activeTab, controls])

validEntities re-runs only when its dependencies change, avoiding unnecessary recomputation on unrelated renders.


Component tree and data flow

App.jsx
│
│  state: matrix, data, correl, analisis, xVar, yVar,
│          controls, selectedEntity, allEntities, selectedEntities
│
│  derived: filteredData, validEntities
│
├── <header>
│     ├── matrix <Select>           → setMatrix → triggers useEffect
│     ├── X axis <AxisCombobox>     → setXVar
│     └── Y axis <AxisCombobox>     → setYVar  (only when activeTab === "dos")
│
├── <Sidebar>
│     props: activeTab, controls, onChange, allEntities (=validEntities),
│             selectedEntities, onSelectedEntitiesChange
│     Emits: onChange(key, value) → handleControlChange → setControls
│
└── <main>
      <Tabs value={activeTab} onValueChange={setActiveTab}>
        │
        ├── "dos" tab
        │     ├── <Scatterplot>
        │     │     props: filteredData, xVar, yVar, controls,
        │     │             selectedEntity, onEntityClick
        │     │     Emits: onEntityClick(entity) → handleEntityClick → setSelectedEntity
        │     │
        │     └── <CorrelationTable>
        │           props: correl, xVar
        │           (pure display — no callbacks)
        │
        └── "una" tab
              └── <Histogram>
                    props: filteredData, xVar, controls,
                            selectedEntity, onEntityClick

AxisCombobox component

A searchable dropdown built with shadcn/ui Popover + Command. It lets the user type to filter a potentially long list of analyte names:

<Popover open={open} onOpenChange={setOpen}>
  <PopoverTrigger asChild>
    <Button ...>{value || placeholder}</Button>
  </PopoverTrigger>
  <PopoverContent>
    <Command>
      <CommandInput placeholder="Buscar variable..." />
      <CommandList>
        {choices.map(choice => (
          <CommandItem
            key={choice}
            value={choice}
            onSelect={() => { onChange(choice); setOpen(false) }}
          >
            <Check ... />
            {choice}
          </CommandItem>
        ))}
      </CommandList>
    </Command>
  </PopoverContent>
</Popover>

It is a stateless component except for open (popover open/closed). The selected value and the list of choices both come from App.jsx.


Scatter plot (Scatterplot.jsx)

A D3 chart rendered imperatively inside a useEffect. The component exposes an SVG element via useRef and a tooltip div.

Fade transition on redraw

Every time the effect runs (when data, xVar, yVar, controls, or selectedEntity change), the SVG fades out, waits 200 ms, redraws everything from scratch, then fades back in:

svgEl.style.opacity = "0"

const timer = setTimeout(() => {
  // ... full D3 redraw ...
  svgEl.style.opacity = "1"
}, 200)

return () => clearTimeout(timer)  // cleanup cancels the timer on fast changes

Data preparation

Null values and, when any log transform is active, non-positive values are filtered out before rendering:

const plotData = data.filter(d =>
  d[xVar] != null && d[yVar] != null &&
  (!(controls.logx || controls.logy || controls.log) || (d[xVar] > 0 && d[yVar] > 0))
)

Log transform modes

Two distinct log transform modes exist, matching the Shiny version’s two approaches:

Control Mechanism Axis labels
controls.log xAccessor = d => Math.log10(d[xVar]) applied in data space Show log10(analyte)
controls.logx / controls.logy d3.scaleLog() applied to the axis scale Show original values on a log scale
const xAccessor = controls.log ? d => Math.log10(d[xVar]) : d => d[xVar]
const xScaleType = controls.logx && !controls.log ? d3.scaleLog : d3.scaleLinear

Clip path

A <clipPath> restricts all data-space elements (points, reference lines) to the plot area, preventing overflow onto the axes:

const clipId = `clip-${Math.random().toString(36).slice(2)}`
svg.append("defs").append("clipPath").attr("id", clipId)
  .append("rect").attr("width", innerW).attr("height", innerH)
const plotArea = g.append("g").attr("clip-path", `url(#${clipId})`)

Axes and labels are appended to g directly (outside the clip path) so they are always fully visible.

Manual slope lines (drawAbline)

Computing a visually correct sloped line requires working in display (axis) space, not data space, to ensure that two lines with the same slope always appear parallel regardless of log/linear axis combinations:

const drawAbline = (slope, intercept) => {
  if (slope == null || intercept == null) return
  const xDataLeft  = xScale.invert(0)       // pixel 0 → data value
  const xDataRight = xScale.invert(innerW)   // pixel innerW → data value

  // Convert x data → display units (log10 if logx scale)
  const uLeft  = (controls.logx && !controls.log) ? Math.log10(xDataLeft)  : xDataLeft
  const uRight = (controls.logx && !controls.log) ? Math.log10(xDataRight) : xDataRight

  // Apply the line equation in display space
  const vLeft  = slope * uLeft  + intercept
  const vRight = slope * uRight + intercept

  // Convert display y → data units for yScale
  const yLeft  = (controls.logy && !controls.log) ? Math.pow(10, vLeft)  : vLeft
  const yRight = (controls.logy && !controls.log) ? Math.pow(10, vRight) : vRight

  plotArea.append("line")
    .attr("x1", 0).attr("x2", innerW)
    .attr("y1", yScale(yLeft)).attr("y2", yScale(yRight))
    .attr("stroke", "green").attr("stroke-width", 1.5)
}

Point hover interaction

On mouseover, the hovered point grows from radius 4 to 7, becomes fully opaque, and gains a white stroke. On mouseout these attributes animate back:

.on("mouseover", function(event, d) {
  d3.select(this).raise()
    .transition().duration(100)
    .attr("r", 7).attr("opacity", 1).attr("stroke", "#fff").attr("stroke-width", 1.5)
  tooltip.style("display", "block")
    .html(`<b>Fracción:</b> ${d.Fracción}<br/>...`)
})
.on("mouseout", function() {
  d3.select(this).transition().duration(100)
    .attr("r", 4).attr("opacity", 0.7).attr("stroke", "none")
  tooltip.style("display", "none")
})

Clickable legend

When controls.ent is true, a scrollable legend is rendered inside an SVG <foreignObject> to the right of the plot area. Clicking an entity row toggles selectedEntity in App.jsx. When an entity is selected, only its points are displayed and all other legend items are dimmed.

The legend right-margin is computed dynamically based on the longest entity name:

const legendWidth = controls.ent && entities.length > 0
  ? Math.max(...entities.map(e => e.length)) * LEGEND_CHAR_WIDTH + LEGEND_PADDING
  : 0
const marginRight = Math.max(MARGIN.right, legendWidth + 15)

Histogram (Histogram.jsx)

Follows the same D3 + useEffect + fade-transition pattern as the scatter plot.

Binning strategy

D3’s d3.bin() always bins in linear space, even when the display axis is logarithmic. To produce visually even bins on a log scale, the bin values are computed in log space and the resulting bin boundaries are back-transformed for display:

const bins = d3.bin()
  .value(d => controls.logx ? Math.log10(d[xVar]) : d[xVar])
  .thresholds(30)(plotData)

// Bar x-positions back-transformed to data space for xScale
const barX  = bin => controls.logx ? xScale(Math.pow(10, bin.x0)) : xScale(bin.x0)
const barX2 = bin => controls.logx ? xScale(Math.pow(10, bin.x1)) : xScale(bin.x1)

Stacked bars by entity

When controls.ent is true, each bin is drawn as a stack of colored segments — one per entity. Each segment’s y position is accumulated as entities are drawn:

let y0 = 0
activeEntities.forEach(ent => {
  const points = bin.filter(d => d.Entidad === ent)
  if (points.length === 0) return
  plotArea.append("rect")
    .attr("y", yScale(y0 + points.length))
    .attr("height", innerH - yScale(points.length))
    // ...
  y0 += points.length
})

Tooltip with row list

Hovering a bar shows a table of the individual samples in that bin (Fracción + value). To avoid very long tooltips, only the first 12 rows are shown with a “+ N more…” footer:

const shown = points.slice(0, MAX_TOOLTIP_ROWS)   // MAX_TOOLTIP_ROWS = 12
const extra = points.length - shown.length

Correlation table (CorrelationTable.jsx)

A pure display component — it receives correl (the loaded correlation array) and xVar and renders a filtered, sorted table with no internal state. It filters to |r| > 0.5 and sorts descending:

const rows = correl
  .filter(row => Math.abs(row[xVar]) > 0.5)
  .sort((a, b) => b[xVar] - a[xVar])

Correlation values are displayed with three decimal places (toFixed(3)). If no correlations exceed the threshold, a “No hay correlaciones” message is shown instead.


Complete data flow summary

User selects matrix
        │
        ▼
useEffect fires → fetch(data.json) + fetch(correl.json) in parallel
        │
        ▼
setData / setCorrel / setAnalisis / setXVar / setYVar /
setAllEntities / setSelectedEntities / setSelectedEntity(null)
        │
        ▼
App re-renders:
  ├── validEntities recomputed (useMemo)
  ├── filteredData recomputed (inline filter)
  ├── Sidebar receives updated validEntities
  ├── AxisCombobox receives updated analisis choices
  └── Charts receive updated filteredData

User changes a control (log scale, entity checkbox...)
        │
        ▼
handleControlChange → setControls (immutable update)
        │
        ▼
filteredData recomputed → passed to charts as prop → D3 useEffect re-runs → fade + redraw

User clicks a legend item in a chart
        │
        ▼
onEntityClick → handleEntityClick → setSelectedEntity (toggle)
        │
        ▼
Chart useEffect re-runs (selectedEntity changed) → fade + redraw with entity highlight

Key differences from the Shiny version

Aspect Shiny (appDatos) React (appDatosReact)
Architecture Server-side R process Client-side static site
Data format .rds binary files .json static files
Rendering ggplot2 + ggplotly Custom D3 v7
Label filter Submit button, filters by Rótulo Removed (field stripped from data for privacy)
Log transform Two separate mechanisms (scale vs. data) Same two mechanisms, unified in controls
Correlation table DT::datatable Plain HTML table
Entity legend pickerInput in sidebar Clickable legend inside the chart SVG
Deployment Shiny server Vite static build → Hostinger

Deployment

  1. Run npm run build — Vite outputs files to dist/ with base: "/app/" applied.
  2. Copy dist/ contents to public_html/app/ on Hostinger.
  3. The .htaccess file (manually maintained in dist/) handles SPA routing so that direct URL access works correctly.