Skip to content

Latest commit

 

History

History
733 lines (602 loc) · 28.1 KB

File metadata and controls

733 lines (602 loc) · 28.1 KB

Plan: Figaro R Package

Context

Figaro is a pure client-side React web app (no backend). Users currently load data by dragging CSV files or clicking "Load examples". The goal is an R package that lets R users pass either data frames or plot objects to figaro() and immediately see them inside the full Figaro UI — no manual file picking required.

Three input modes are supported:

  • Data mode: figaro(iris = iris) — data frame is loaded as a dataset; user builds plots interactively in the UI.
  • Plot mode: figaro(scatter = my_ggplot) — an existing R plot object (ggplot2 or base R) is rasterized to PNG and inserted as an image panel. Simple plots are extracted as native Figaro plots (fully editable); complex ones get an interactive re-render API via the local R server.
  • File mode: figaro(fig = "path/to/figure.png") or figaro(fig = "path/to/figure.pdf") — a pre-rendered PNG, JPEG, or PDF file is loaded directly as an image panel. No R plot object required; useful when the figure was produced outside R or by a tool with no plot object API.

All modes can be mixed in a single call: figaro(data = iris, fig = my_plot, extra = "figure.pdf").

The R package bundles the Figaro production build and serves it locally via httpuv. On each launch it injects the user's data directly into the page HTML so the browser loads Figaro with the datasets and images already present.


Architecture Overview

R session
  └─ figaro(data = iris, fig = my_ggplot, extra = "fig.pdf")
       ├─ iris (data frame)  → dataset entry + rows in fileData
       ├─ my_ggplot (ggplot) → extract native plot OR rasterize PNG → imageRef
       ├─ "fig.pdf" (path)   → read file, rasterize if PDF → PNG data URL → imageRef
       ├─ build session JSON (layout, panels, datasets, imageRefs, fileData)
       ├─ start httpuv on localhost:PORT
       │    ├─ GET /          → index.html with injected <script>
       │    └─ GET /assets/*  → static bundle files
       └─ browseURL("http://localhost:PORT")

Browser
  └─ React mounts → reads window.__FIGARO_INITIAL_SESSION__
       └─ loadSession() + attachLoaded()
            → data frame visible in DataManager
            → ggplot visible as image panel, ready to label & compose

Phase 1 — Vite config: add an R-package build mode

File: vite.config.js

Currently base: '/figaro-/' — assets are served at /figaro-/assets/..., which breaks when the app is served from a plain localhost:PORT.

Change: support a second build mode with base: './' (relative paths).

// vite.config.js
base: process.env.VITE_BASE_PATH ?? '/figaro-/',

Add an npm script using cross-env, which normalizes environment-variable syntax across all platforms (macOS, Linux, Windows):

"build:r": "cross-env VITE_BASE_PATH=./ vite build --outDir dist-r"

Install cross-env as a dev dependency (npm i -D cross-env). This works identically on Mac/Linux (where VAR=val cmd already works natively) and on Windows (where cmd.exe requires a different syntax). Using cross-env means any developer can run npm run build:r regardless of their OS.


Phase 2 — Web app: initial session injection hook

File: src/App.jsx

Add one block at the top of the existing restoreSession useEffect that checks for window.__FIGARO_INITIAL_SESSION__ (set by the R server at page-load time). If present, load it instead of falling back to IndexedDB.

useEffect(() => {
  const injected = window.__FIGARO_INITIAL_SESSION__;
  if (injected) {
    const { session, loaded } = injected;
    loadSession(session);
    for (const [id, payload] of Object.entries(loaded || {})) {
      attachLoaded(id, payload);
    }
    requestCanvasFit();
    return;               // skip IndexedDB restore
  }
  // ... existing restoreSession() logic unchanged
}, []);

loadSession and attachLoaded are already in the Zustand store. After this change, rebuild: npm run build:r → output lands in dist-r/.


Phase 3 — R package scaffold

Create a new directory figaro-r/ (sibling to figaro-/).

Directory structure

figaro-r/
├── DESCRIPTION
├── NAMESPACE
├── R/
│   ├── figaro.R        # figaro() — main entry point
│   ├── session.R       # build_session() — R → session JSON
│   ├── server.R        # start_server() / stop_server()
│   └── utils.R         # id generators, type inference helpers
├── inst/
│   └── www/            # copy of dist-r/ after npm run build:r
│       ├── index.html
│       └── assets/
├── man/                # auto-generated by roxygen2
└── tests/
    └── testthat/
        ├── test-session.R
        └── test-server.R

DESCRIPTION (key fields)

Package: figaro
Title: Interactive Scientific Figure Composer
Version: 0.1.0
Authors@R: c(
    person("Yuval", "Bloch", role = "aut"),
    person("Kesem", "Abramov", role = "aut"),
    person("Natan", "Edelman", role = "aut"))
Imports: jsonlite, httpuv, utils
Suggests: ggplot2, pdftools, png, testthat (>= 3.0.0)
License: MIT
Encoding: UTF-8

Phase 4 — R: data conversion (R/session.R)

Input type detection (R/utils.R)

Each item in ... is classified before processing:

classify_input <- function(x) {
  if (is.data.frame(x))            return("dataset")
  if (inherits(x, "gg"))           return("ggplot")      # ggplot2 object
  if (inherits(x, "recordedplot")) return("baseplot")    # base R recorded plot
  if (is.character(x) && length(x) == 1 && file.exists(x)) {
    ext <- tolower(tools::file_ext(x))
    if (ext %in% c("png", "jpg", "jpeg")) return("image_file")
    if (ext == "pdf")                      return("pdf_file")
    stop("Unsupported file type: .", ext, ". Supported: png, jpg, jpeg, pdf.")
  }
  stop("Unsupported input type: ", class(x)[1],
       ". Pass a data frame, a ggplot/recordedPlot object, or a path to a PNG/PDF file.")
}

file_to_data_url(path, type) (R/utils.R)

Converts an image/PDF file to a PNG data URL.

  • PNG / JPEG: read raw bytes, base64-encode directly:
    raw_bytes <- readBin(path, "raw", file.info(path)$size)
    mime <- if (ext %in% c("jpg","jpeg")) "image/jpeg" else "image/png"
    paste0("data:", mime, ";base64,", jsonlite::base64_enc(raw_bytes))
  • PDF: rasterize the first page using pdftools::pdf_render_page() (adds pdftools to Suggests; emit a helpful error if not installed):
    img_matrix <- pdftools::pdf_render_page(path, page = 1, dpi = 150)
    tmp <- tempfile(fileext = ".png")
    on.exit(unlink(tmp))
    png::writePNG(img_matrix, tmp)   # 'png' package (base R on Mac/Linux; Suggests on Windows)
    raw_bytes <- readBin(tmp, "raw", file.info(tmp)$size)
    paste0("data:image/png;base64,", jsonlite::base64_enc(raw_bytes))
    Only the first page is imported. A message() informs the user if a multi-page PDF is detected.

R plot handling — two paths

When an R plot object is passed, Figaro uses the richest path available:

Path A — Native Figaro plot (ggplot2, single-layer, supported geom):
Extract the underlying data, aesthetic mappings, labels and theme settings from the ggplot2 object and create a real Figaro plot entry. The plot lands in the UI exactly as if the user had dragged the data and configured the chart type themselves — font sizes, titles, axis labels, and column assignments are all editable via the Plot Inspector.

Path B — Rasterized image (fallback):
Used when the ggplot is too complex to extract (multi-layer, unsupported geom, no data embedded) or when the input is a base R recordedPlot. The plot is rasterized to a PNG data URL and inserted as an image panel. The user can resize, label, and compose it, but cannot edit internal chart elements.

ggplot_to_figaro(p, ds_id, plot_id) (R/session.R)

Attempts Path A. Returns list(plot = <plot_obj>, dataset = <df>, ok = TRUE) on success, or list(ok = FALSE, reason = "...") on fallback.

Steps:

  1. Check simplicity: exactly one layer, layer data is a data frame (or inherits from the top-level p$data), no faceting (is.null(p$facet) or FacetNull). If any condition fails → return ok = FALSE.
  2. Detect chart type from class(p$layers[[1]]$geom)[1]:
    • GeomPoint"scatter"
    • GeomLine, GeomPath"line"
    • GeomBar, GeomCol"bar"
    • GeomBoxplot"box"
    • GeomHistogram"histogram"
    • Anything else → return ok = FALSE (fallback to image)
  3. Extract column names from p$mapping: x, y, colour/color, size, fill (use rlang::as_label() or deparse(p$mapping$x) to get the column name string).
  4. Extract labels from p$labels: title, x (→ xLabel), y (→ yLabel).
  5. Extract base font size from p$theme$text$size (fall back to 12 if absent).
  6. Return a Figaro plot list mirroring the shape in src/store/plotsSlice.js:
    list(
      id = plot_id, type = chart_type, datasetId = ds_id,
      xCol = x_col, yCol = y_col, colorCol = color_col,   # NULL if not mapped
      title = title, xLabel = x_label, yLabel = y_label,
      fontSize = font_size
      # remaining fields use Figaro defaults (colors, marker size, etc.)
    )
    Implementation note: before writing this function, read src/store/plotsSlice.js and src/components/controls/fields.jsx to confirm exact field names and defaults for each chart type. The R list must match those field names exactly so Figaro deserialises them correctly.

plot_to_data_url(plot_obj, type) (R/utils.R)

Fallback rasterizer. Returns a PNG data URL using jsonlite::base64_enc().

plot_to_data_url <- function(plot_obj, type, width_in = 7, height_in = 5, dpi = 150) {
  tmp <- tempfile(fileext = ".png")
  on.exit(unlink(tmp))
  if (type == "ggplot") {
    ggplot2::ggsave(tmp, plot = plot_obj, width = width_in,
                    height = height_in, units = "in", dpi = dpi)
  } else {                          # recordedplot
    grDevices::png(tmp, width = width_in * dpi, height = height_in * dpi)
    grDevices::replayPlot(plot_obj)
    grDevices::dev.off()
  }
  raw_bytes <- readBin(tmp, "raw", file.info(tmp)$size)
  paste0("data:image/png;base64,", jsonlite::base64_enc(raw_bytes))
}

build_session(inputs, name, canvas_preset)

Processes a named list where each item is a data frame or plot object.

Key steps:

  1. Data frames → dataset pipeline:

    • Generate ds_<id>; infer column types (numeric → "number", others → "string")
    • Add to datasets metadata + fileData as list(type="dataset", rows=...)
    • Panel type: "empty" (user wires a chart type in the UI)
  2. ggplot2 objects → try Path A first:

    • Call ggplot_to_figaro(p, ds_id, plot_id).
    • Success: add extracted data frame as a dataset (ds_<id>) + fileData rows; add the Figaro plot entry to plots; panel type = "plot" referencing plot_id. Result: fully editable chart with pre-filled column assignments, title, font size.
    • Fallback: call plot_to_data_url()imageRef + fileData image entry; panel type = "image"; emit a message() telling the user what was not extracted.
  3. Base R recordedPlot → always Path B (image): plot_to_data_url()imageRef + fileData image; panel type = "image".

  4. File paths (PNG / JPEG / PDF) → image pipeline:

    • Call file_to_data_url(path, type) → PNG/JPEG data URL
    • Add to imageRefs + fileData as image entry; panel type = "image"
    • For PDF: rasterize first page; warn if multi-page
  5. Build layout: 1 × N grid where N = total number of items.

  6. Return a list matching the .figaro.json schema:

    list(
      schemaVersion = "1.1.0",
      meta = list(name = name, createdAt = iso_now(), modifiedAt = iso_now()),
      canvas = canvas_preset_to_list(canvas_preset),
      layout = default_layout(n_cols),
      panels = default_panels(regions, panel_specs),
      plots  = plots_list,
      datasets = datasets_meta,
      imageRefs = image_refs_meta,
      theme = default_theme(),
      labeling = default_labeling(),
      customPalette = default_palette(),
      fileData = file_data
    )

figaro_save(session, path)

Writes session to a .figaro.json file via jsonlite::write_json(session, path, auto_unbox = TRUE).


Phase 4b — Interactive re-render for complex R plots

For ggplot2 objects that cannot be extracted natively (multi-layer, faceted, unsupported geom), Figaro uses image fallback but still allows editing visual properties through a dedicated R Plot Style inspector in the web UI.

How it works

  1. The R server stores each ggplot object in a named in-memory list (r_plots_env) keyed by the same img_<id> used in imageRefs. The stored object is the original, unmodified ggplot2 value.

  2. At page-load time, the server URL is injected alongside the session:

    window.__FIGARO_INITIAL_SESSION__ = { session, loaded };
    window.__FIGARO_R_SERVER__ = "http://localhost:<PORT>";

    The web app reads window.__FIGARO_R_SERVER__ and stores it in a module constant. When this value is null / undefined (normal browser use), all R-server features are silently disabled.

  3. When the user clicks an image panel whose imageRef has rPlot: true, Figaro shows a "R Plot Style" inspector panel (web-side addition) with controls:

    • Title text field
    • X / Y axis label text fields
    • Base font size slider (8–24)
    • Legend position selector (right / bottom / none)
    • ggplot2 colour palette selector (default / viridis / RColorBrewer palettes)
    • Width × Height (inches) for the rasterisation
  4. On any change, the web app sends:

    POST <R_SERVER>/restyle
    { "plotId": "img_xxx", "title": "...", "fontSize": 14, ... }
    

    and replaces the panel's src / blobURL with the returned PNG data URL.

R server additions (R/server.R)

New endpoint in start_server():

if (path == "/restyle" && req$REQUEST_METHOD == "POST") {
  body <- jsonlite::fromJSON(rawToChar(req$rook.input$read()))
  plot_id <- body$plotId
  p <- r_plots_env[[plot_id]]
  if (is.null(p)) return(list(status = 404L, headers = list(), body = "Not found"))

  if (!is.null(body$title))    p <- p + ggplot2::ggtitle(body$title)
  if (!is.null(body$xLabel))   p <- p + ggplot2::xlab(body$xLabel)
  if (!is.null(body$yLabel))   p <- p + ggplot2::ylab(body$yLabel)
  if (!is.null(body$fontSize)) p <- p + ggplot2::theme(text = ggplot2::element_text(size = body$fontSize))
  if (!is.null(body$legendPos)) p <- p + ggplot2::theme(legend.position = body$legendPos)

  data_url <- plot_to_data_url(p, "ggplot",
                               width_in  = body$widthIn  %||% 7,
                               height_in = body$heightIn %||% 5)
  return(list(status = 200L,
              headers = list("Content-Type" = "application/json",
                             "Access-Control-Allow-Origin" = "*"),
              body = jsonlite::toJSON(list(dataUrl = data_url), auto_unbox = TRUE)))
}

Figaro web UI addition (new component)

A small new component RPlotStyleInspector (in src/components/inspect/) is shown in the right-side inspector when the selected panel is an image panel with rPlot: true in its imageRef. Controls map to the /restyle API fields above.

The imageRefs schema gains one optional field:

{ "id": "img_xxx", "name": "complex", "sourceFile": "", "rPlot": true }

This is backwards-compatible — existing image refs without rPlot behave as before.

Session persistence note

The re-render API is session-only: the user's ggplot object lives in the R process's memory and is lost if the server stops. The modified PNG is embedded in the session JSON if the user saves (File > Save) — so the result is portable even without R.


Phase 5 — R: httpuv server (R/server.R)

start_server(session_json_string, port)

start_server <- function(session_json, port = NULL) {
  if (is.null(port)) port <- httpuv::randomPort()
  www_dir <- system.file("www", package = "figaro")

  server <- httpuv::startServer("0.0.0.0", port, list(
    call = function(req) {
      path <- req$PATH_INFO

      if (path == "/" || path == "/index.html") {
        html <- readLines(file.path(www_dir, "index.html"), warn = FALSE)
        inject <- sprintf(
          '<script>window.__FIGARO_INITIAL_SESSION__ = %s; window.__FIGARO_R_SERVER__ = "http://localhost:%d";</script>',
          session_json, port
        )
        html <- sub("</body>", paste0(inject, "\n</body>"),
                    paste(html, collapse = "\n"))
        return(list(status = 200L,
                    headers = list("Content-Type" = "text/html"),
                    body = html))
      }

      file_path <- file.path(www_dir, sub("^/", "", path))
      if (file.exists(file_path)) {
        return(list(status = 200L,
                    headers = list("Content-Type" = mime_type(file_path)),
                    body = readBin(file_path, "raw", file.info(file_path)$size)))
      }
      list(status = 404L, headers = list(), body = "Not found")
    }
  ))
  list(server = server, port = port)
}

Phase 6 — R: main entry point (R/figaro.R)

#' Open the Figaro figure composer with pre-loaded data or plots
#'
#' Each named argument can be:
#'   - a data frame (loaded as a dataset the user can assign to plots)
#'   - an R plot object (ggplot2 or base R recordedPlot)
#'   - a character string: path to an existing PNG, JPEG, or PDF file
#' All three types can be mixed in one call:
#'   figaro(data = iris, scatter = my_ggplot, extra = "figure.pdf")
#'
#' @param ... Named inputs: data frames, ggplot/recordedPlot objects, or file paths.
#' @param session Path to an existing .figaro.json file to open (overrides ...)
#' @param canvas Canvas size preset. One of "A4_portrait", "A4_landscape",
#'   "letter_portrait", "slide_16_9", etc.
#' @param port Local port. Auto-assigned if NULL.
#' @param launch Open the browser automatically. Default TRUE.
#' @export
figaro <- function(..., session = NULL, canvas = "A4_portrait",
                   port = NULL, launch = TRUE) {
  inputs <- list(...)

  if (!is.null(session)) {
    sess_obj <- jsonlite::read_json(session)
  } else {
    sess_obj <- build_session(inputs, canvas_preset = canvas)
  }

  loaded <- build_loaded(sess_obj)

  payload <- jsonlite::toJSON(
    list(session = sess_obj, loaded = loaded),
    auto_unbox = TRUE, null = "null"
  )

  srv <- start_server(payload, port)
  url <- paste0("http://localhost:", srv$port)
  message("Figaro running at ", url)
  message("Press Ctrl+C (or call figaro_stop()) to stop the server.")

  .figaro_env$server <- srv$server
  .figaro_env$port   <- srv$port

  if (launch) utils::browseURL(url)
  invisible(srv)
}

#' @export
figaro_stop <- function() {
  if (!is.null(.figaro_env$server)) {
    .figaro_env$server$stop()
    .figaro_env$server <- NULL
    message("Figaro server stopped.")
  }
}

.figaro_env <- new.env(parent = emptyenv())

Phase 7 — Testing plan

Unit tests (tests/testthat/test-session.R)

test_that("build_session produces valid schema version", {
  sess <- build_session(list(iris = iris))
  expect_equal(sess$schemaVersion, "1.1.0")
})

test_that("all iris rows are present in fileData", {
  sess <- build_session(list(iris = iris))
  ds_id <- names(sess$datasets)[1]
  expect_equal(length(sess$fileData[[ds_id]]$rows), nrow(iris))
})

test_that("column types are inferred correctly", {
  sess <- build_session(list(iris = iris))
  ds_id <- names(sess$datasets)[1]
  cols <- sess$datasets[[ds_id]]$columns
  sepal_col <- Filter(function(c) c$name == "Sepal.Length", cols)[[1]]
  expect_equal(sepal_col$type, "number")
  species_col <- Filter(function(c) c$name == "Species", cols)[[1]]
  expect_equal(species_col$type, "string")
})

test_that("figaro_save round-trips correctly", {
  sess <- build_session(list(iris = iris))
  tmp <- tempfile(fileext = ".figaro.json")
  figaro_save(sess, tmp)
  reloaded <- jsonlite::read_json(tmp)
  expect_equal(reloaded$schemaVersion, "1.1.0")
})

test_that("simple ggplot2 scatter is extracted as a native Figaro plot", {
  skip_if_not_installed("ggplot2")
  p <- ggplot2::ggplot(iris, ggplot2::aes(Sepal.Length, Sepal.Width)) +
    ggplot2::geom_point()
  sess <- build_session(list(scatter = p))
  expect_length(sess$plots, 1)
  expect_equal(sess$plots[[1]]$type, "scatter")
  expect_equal(sess$plots[[1]]$xCol, "Sepal.Length")
  expect_equal(sess$plots[[1]]$yCol, "Sepal.Width")
  panel_types <- vapply(sess$panels, `[[`, character(1), "type")
  expect_true("plot" %in% panel_types)
})

test_that("ggplot labels and font size are transferred to Figaro plot", {
  skip_if_not_installed("ggplot2")
  p <- ggplot2::ggplot(iris, ggplot2::aes(Sepal.Length, Sepal.Width)) +
    ggplot2::geom_point() +
    ggplot2::labs(title = "Iris scatter", x = "Sepal L", y = "Sepal W") +
    ggplot2::theme(text = ggplot2::element_text(size = 14))
  sess <- build_session(list(p = p))
  plt <- sess$plots[[1]]
  expect_equal(plt$title, "Iris scatter")
  expect_equal(plt$xLabel, "Sepal L")
  expect_equal(plt$fontSize, 14)
})

test_that("complex ggplot (multi-layer) falls back to image", {
  skip_if_not_installed("ggplot2")
  p <- ggplot2::ggplot(iris, ggplot2::aes(Sepal.Length, Sepal.Width)) +
    ggplot2::geom_point() +
    ggplot2::geom_smooth()
  sess <- build_session(list(complex = p))
  expect_length(sess$imageRefs, 1)
  img_id <- names(sess$imageRefs)[1]
  expect_true(startsWith(sess$fileData[[img_id]]$dataUrl, "data:image/png;base64,"))
})

test_that("PNG file path is converted to imageRef with data URL", {
  skip_if_not_installed("ggplot2")
  tmp_png <- tempfile(fileext = ".png")
  ggplot2::ggsave(tmp_png,
    ggplot2::ggplot(iris, ggplot2::aes(Sepal.Length, Sepal.Width)) +
      ggplot2::geom_point(),
    width = 5, height = 4)
  sess <- build_session(list(fig = tmp_png))
  img_id <- names(sess$imageRefs)[1]
  expect_true(startsWith(sess$fileData[[img_id]]$dataUrl, "data:image/png;base64,"))
  panel_types <- vapply(sess$panels, `[[`, character(1), "type")
  expect_true("image" %in% panel_types)
})

test_that("mixed data frame + ggplot call produces plot + empty panel", {
  skip_if_not_installed("ggplot2")
  p <- ggplot2::ggplot(iris, ggplot2::aes(Sepal.Length, Sepal.Width)) +
    ggplot2::geom_point()
  sess <- build_session(list(data = iris, fig = p))
  panel_types <- vapply(sess$panels, `[[`, character(1), "type")
  expect_true("empty" %in% panel_types)
  expect_true("plot" %in% panel_types)
})

Integration test (tests/testthat/test-server.R)

test_that("server serves index.html with injected session", {
  sess <- build_session(list(iris = iris))
  payload <- jsonlite::toJSON(list(session = sess, loaded = build_loaded(sess)),
                              auto_unbox = TRUE)
  srv <- start_server(payload, port = 7654)
  on.exit(srv$server$stop())

  resp <- httr::GET("http://localhost:7654/")
  expect_equal(httr::status_code(resp), 200)
  body <- httr::content(resp, "text")
  expect_true(grepl("__FIGARO_INITIAL_SESSION__", body))
})

Manual end-to-end tests

library(figaro)

# Test 1: data frame → user builds plots interactively
figaro(iris = iris)
# → browser opens; iris appears in DataManager
# → drag iris onto panel → assign scatter → verify render
# → File > Save → downloads .figaro.json

# Test 2: ggplot2 → extracted as native Figaro plot (fully editable)
library(ggplot2)
p <- ggplot(iris, aes(Sepal.Length, Sepal.Width, color = Species)) + geom_point() +
  labs(title = "Iris", x = "Sepal L", y = "Sepal W") +
  theme(text = element_text(size = 14))
figaro(scatter = p)
# → scatter panel with pre-filled axes, title, font size
# → Plot Inspector shows chart type + columns pre-set; font size slider works

# Test 3: complex ggplot → image fallback + R Plot Style inspector
p2 <- ggplot(iris, aes(Sepal.Length, Sepal.Width)) + geom_point() + geom_smooth()
figaro(complex = p2)
# → R console: "figaro: 'complex' has multiple layers — inserted as image panel"
# → click panel → R Plot Style inspector; change font size → re-renders

# Test 4: mixed call
figaro(data = iris, fig = p)
# → editable scatter panel + empty dataset panel; drag iris → assign bar chart

# Test 5: PNG file path
figaro(fig = "path/to/fig.png")
# → image panel renders immediately

# Test 6: PDF file path
figaro(fig = "path/to/fig.pdf")
# → first page imported; warning shown if multi-page

Phases summary for PROGRESS.md

## Phase R1 — Vite: R-package build mode [ ]
- [ ] Add VITE_BASE_PATH env var to vite.config.js
- [ ] Install cross-env dev dependency (works on Mac/Linux/Windows)
- [ ] Add `build:r` npm script (outputs to dist-r/)

## Phase R2 — Web app: initial session injection [ ]
- [ ] Add window.__FIGARO_INITIAL_SESSION__ hook to src/App.jsx
- [ ] Run `npm run build:r` and copy dist-r/ → figaro-r/inst/www/

## Phase R3 — R package scaffold [ ]
- [ ] Create figaro-r/ directory with DESCRIPTION (authors: Bloch, Abramov, Edelman), NAMESPACE, R/, inst/www/, tests/
- [ ] R/utils.R: classify_input(), plot_to_data_url(), file_to_data_url(), id generator, ISO timestamp
- [ ] R/session.R: ggplot_to_figaro(), build_session(), figaro_save(), build_loaded()
- [ ] R/server.R: start_server(), mime_type(), /restyle endpoint, r_plots_env storage
- [ ] R/figaro.R: figaro(), figaro_stop(), .figaro_env
- [ ] src/components/inspect/RPlotStyleInspector.jsx — style controls for image-backed R plots
- [ ] Inject window.__FIGARO_R_SERVER__ alongside session in start_server()

## Phase R4 — Tests [ ]
- [ ] Unit: session schema, column types, round-trip JSON
- [ ] Unit: simple ggplot2 → native plot with pre-filled xCol/yCol/title/fontSize
- [ ] Unit: ggplot labels + font size transfer
- [ ] Unit: complex multi-layer ggplot → image fallback
- [ ] Unit: mixed data frame + ggplot → correct panel types
- [ ] Unit: PNG file path → imageRef with data URL
- [ ] Integration: server serves index.html with injected session
- [ ] Manual 1: figaro(iris) → drag → assign scatter → export PNG
- [ ] Manual 2: figaro(scatter = ggplot) → all fields editable via Plot Inspector
- [ ] Manual 3: figaro(complex) → image fallback; R Plot Style inspector re-renders on change
- [ ] Manual 4: figaro(data = iris, fig = ggplot) → mixed layout
- [ ] Manual 5: figaro(fig = "fig.png") → image panel
- [ ] Manual 6: figaro(fig = "fig.pdf") → first page; multi-page warning
- [ ] R CMD check: 0 errors, 0 warnings

## Phase R5 — Documentation & CRAN prep [ ]
- [ ] roxygen2 @param/@export docs on all exported functions
- [ ] vignette: "Getting started with figaro"
- [ ] README.md with install + quick-start example

Critical files

File Change
figaro-/vite.config.js Add VITE_BASE_PATH env support
figaro-/package.json Add cross-env dev dep + build:r script
figaro-/src/App.jsx Add window.__FIGARO_INITIAL_SESSION__ hook
figaro-r/R/utils.R New — classify_input, plot_to_data_url, file_to_data_url
figaro-r/R/session.R New — ggplot_to_figaro, build_session, figaro_save
figaro-r/R/server.R New — httpuv server + /restyle endpoint
figaro-r/R/figaro.R New — main entry point
figaro-r/inst/www/ Copy of dist-r/ build
figaro-/src/components/inspect/RPlotStyleInspector.jsx New — style controls for image-backed R plots

Dependencies

R package (Imports): jsonlite, httpuv, tools
R package (Suggests):

  • ggplot2 — for passing ggplot2 objects
  • pdftools — for PDF file path input (rasterizes first page)
  • png — for PDF rasterization on Windows
  • grDevices — base R; for recordedPlot rasterization

Dev only: testthat, httr
Web app: cross-env (dev dep — cross-platform env var syntax for build:r script)

Notes for a first-time R package author

  • devtools::create("figaro-r") scaffolds DESCRIPTION + NAMESPACE automatically
  • devtools::document() generates man/ from roxygen2 comments
  • devtools::load_all() lets you test without installing
  • devtools::check() runs R CMD check
  • devtools::install() installs locally for end-to-end testing
  • The inst/www/ folder is copied verbatim into the installed package; system.file("www", package = "figaro") locates it at runtime