Skip to content

Refactor animint.js into ES modules with a build system #313

@AviraL0013

Description

@AviraL0013

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions