appDatosReact: Logic and Workflow
Technical documentation of the React application
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 changesData 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.scaleLinearClip 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.lengthCorrelation 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
- Run
npm run build— Vite outputs files todist/withbase: "/app/"applied. - Copy
dist/contents topublic_html/app/on Hostinger. - The
.htaccessfile (manually maintained indist/) handles SPA routing so that direct URL access works correctly.