Problem
animint.js is a single ~2759-line file that handles everything — rendering, data loading, interactions, animation, and widgets — all inside one large closure. This makes the codebase:
- Hard to navigate — finding the code responsible for a specific geom or behaviour requires searching through hundreds of unrelated lines
- Hard to contribute to — fixing a bug in
geom_point requires understanding the entire file
- Untestable in isolation — there are no JavaScript unit tests because there are no modules to import individually and test
- Hard to maintain — draw_geom() alone is ~880 lines handling all geom types via a long if/else chain
- Having worked inside
draw_geom() myself, even small bug fixes require reading the entire function to avoid missing side effects.
As a concrete example, the current structure inside draw_geom():
// 880 lines of if/else:
if (g_info.geom == "segment") { ... }
if (g_info.geom == "linerange") { ... }
if (g_info.geom == "vline") { ... }
if (g_info.geom == "hline") { ... }
if (g_info.geom == "text") { ... }
if (g_info.geom == "point") { ... }
if (g_info.geom == "label_aligned") { ... }
var rect_geoms = ["tallrect","widerect","rect"];
if (rect_geoms.includes(g_info.geom)) { ... }
Proposed Solution
Refactor animint.js into ES modules under inst/htmljs/src/, using Rollup as a bundler to produce the same animint.js output the browser loads.
Backward compatibility is fully preserved. The browser-facing output stays as a single <script>-loadable animint.js file. The modularization is purely a developer-side improvement.
Proposed Module Structure
inst/htmljs/
├── src/
│ ├── index.js # animint constructor — owns all mutable state (Selectors, Plots, Geoms, etc.)
│ ├── init.js # d3.json() callback — orchestrates initialization
│ ├── utils.js # safe_name, linetypesize2dasharray, convert_R_types, isArray, measureText
│ ├── data.js # download_chunk, copy_chunk, update_geom, draw_panels
│ ├── add-plot.js # add_plot() — panel layout, axes, strips, backgrounds (layout math inline)
│ ├── geoms/
│ │ ├── base.js # Shared accessor factories: get_size, get_colour, get_fill, get_alpha, etc.
│ │ ├── draw-geom.js # draw_geom() — data filtering, style dispatch, enter/exit/update
│ │ ├── line-geoms.js # line, path, polygon, ribbon, segment, abline, vline, hline, linerange
│ │ ├── point.js # circle rendering
│ │ ├── rect.js # rect, tallrect, widerect
│ │ ├── text.js # text rendering
│ │ └── label-aligned.js # quadprog-optimized non-overlapping labels
│ ├── interactions.js # add_selector, update_selector, click handlers, hover styles, tooltips
│ ├── axes.js # update_scales, update_axes, update_grids, apply_axis_text_styles
│ ├── legend.js # add_legend, legend SVG element creation
│ └── widgets.js # selector menus, animation controls, tour, loading table
├── animint.js # Built output (generated by Rollup, not edited by hand)
├── animint.min.js # Minified output
├── rollup.config.js # Build configuration
└── package.json # devDependencies: rollup, @rollup/plugin-terser
~14 files instead of one 2759-line monolith. Average file size: ~120–200 lines.
Design Decisions
State ownership: The animint constructor in index.js owns all mutable state (Selectors, Plots, Geoms, etc.) as closure variables. Modules receive what they need as function parameters — they do not import shared mutable state. This avoids hidden coupling.
// geoms/point.js — pure function, no shared state
export function pointActions(scales) {
return function(e) {
e.attr("cx", d => scales.x(d.x))
.attr("cy", d => scales.y(d.y))
.attr("r", d => d.size);
};
}
Build output format: Rollup iife format — produces the same global var animint = function(...) {...} that index.html already uses, so no changes are needed to the R side or to animint2dir() output.
Build System
// rollup.config.js
import terser from '@rollup/plugin-terser';
export default {
input: 'src/index.js',
output: [
{ file: 'animint.js', format: 'iife', name: 'animint' },
{ file: 'animint.min.js', format: 'iife', name: 'animint', plugins: [terser()] }
],
external: ['d3'] // D3 loaded via <script> tag
};
// package.json (additions)
{
"scripts": {
"build": "rollup -c",
"watch": "rollup -c --watch"
},
"devDependencies": {
"rollup": "^4.0.0",
"@rollup/plugin-terser": "^0.4.0"
}
}
Phased Approach
To keep things safe and reviewable, this can be split into phases:
| Phase |
Description |
Risk |
| 1 |
Set up Rollup, split into modules with **zero logic changes **, verify identical output |
🟢 Low |
| 2 |
Break apart draw_geom() into per-geom modules |
🟡 Medium |
| 3 |
Add JavaScript unit tests (Vitest/Jest) per module |
🟢 Low |
| 4 |
Upgrade D3 v3 → v7 (separate issue, but enabled by modules) |
🔴 High |
Verification for Phase 1: Run the full existing R/chromote test suite against the Rollup output. Since Phase 1 involves zero logic changes, any failure points directly to a bundling or initialization-order issue, not a logic regression.
Phases 1–3 could be done as a single GSoC project. Phase 4 is a separate follow-up issue.
Benefits
| Before |
After |
| 1 file, 2759 lines |
~14 files, ~150 lines avg |
| No JS unit tests possible |
Each module independently testable |
| Contributors must read entire file |
Contributors work in one focused file |
| No minification |
animint.min.js (~40% smaller) |
| draw_geom is 880 lines |
Dispatcher + small geom files |
| Hard to onboard new contributors |
File name = feature area |
@tdhock
Problem
animint.js is a single ~2759-line file that handles everything — rendering, data loading, interactions, animation, and widgets — all inside one large closure. This makes the codebase:
geom_pointrequires understanding the entire filedraw_geom()myself, even small bug fixes require reading the entire function to avoid missing side effects.As a concrete example, the current structure inside draw_geom():
Proposed Solution
Refactor animint.js into ES modules under
inst/htmljs/src/, using Rollup as a bundler to produce the same animint.js output the browser loads.Proposed Module Structure
~14 files instead of one 2759-line monolith. Average file size: ~120–200 lines.
Design Decisions
State ownership: The animint constructor in
index.jsowns all mutable state (Selectors,Plots,Geoms, etc.) as closure variables. Modules receive what they need as function parameters — they do not import shared mutable state. This avoids hidden coupling.Build output format: Rollup
iifeformat — produces the same globalvar animint = function(...) {...}that index.html already uses, so no changes are needed to the R side or toanimint2dir()output.Build System
Phased Approach
To keep things safe and reviewable, this can be split into phases:
Verification for Phase 1: Run the full existing R/chromote test suite against the Rollup output. Since Phase 1 involves zero logic changes, any failure points directly to a bundling or initialization-order issue, not a logic regression.
Phases 1–3 could be done as a single GSoC project. Phase 4 is a separate follow-up issue.
Benefits
animint.min.js(~40% smaller)@tdhock