Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .Rbuildignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@
^inst/appTester/e2e_output$
^docs$
^\.github$
^\.git$
^_pkgdown\.yml$
^pkgdown$
^LICENSE\.md$
^cran-comments\.md$
^\.claude$
^\.agents$
^\.codex$
^\.do$
^app$
^coverage$
Expand All @@ -38,6 +41,7 @@
^tests/prototypes/shiny-transport$
^tests/prototypes$
^tests/playwright$
^tests/webr$
^playwright\.config\.ts$
^inst/htmlwidgets/myIO/src/.*\.test\.js$
^inst/htmlwidgets/myIO/src/coordinator/__tests__$
Expand Down
10 changes: 8 additions & 2 deletions .agents/skills/cut-release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ confirmation link, so full automation stops one step short of that by constructi

## Step 1 — Re-derive scope (don't trust a stale snapshot)

- Hard preflight: require `git status --porcelain` to be empty, require the current branch to be
`main`, fetch `origin`, and require `main`, its upstream, and `origin/main` to resolve to the same
commit. If any condition fails, stop before changing release metadata. Never release from a dirty,
detached, ahead, or behind worktree.
- `git tag --sort=-v:refname | head -1` — last released tag (e.g. `v1.2.0`).
- `git log {last_tag}..HEAD --oneline --no-merges` — everything merged since, including anything
`backlog-pipeline`/`idea-scout` landed after this skill was written. Read every commit.
Expand Down Expand Up @@ -58,8 +62,10 @@ package differentiation, final checks. Get a verdict.

1. `R CMD build . --no-manual` then `R CMD check --as-cran` on the resulting tarball — confirm
0 errors / 0 warnings, and every NOTE is one already documented in `cran-comments.md`.
2. Commit `DESCRIPTION` + `NEWS.md` directly to `main` (not a feature branch — this is a release
commit, matching how prior releases in this repo were tagged directly on main).
2. Review `git status --short` and commit every release-metadata file changed by the workflow,
including `DESCRIPTION`, `NEWS.md`, `cran-comments.md`, generated documentation, schemas, and
checksums where applicable. Do not tag with any workflow-generated metadata left uncommitted.
The release commit is made directly on `main`, matching how prior releases were tagged.
3. `git tag -a v{version} -m "Release v{version}"` and `git push origin main --tags`.
4. `gh release create v{version}` with notes drawn from the finalized NEWS.md section (customer-
facing summary), following the format `/release` already uses.
Expand Down
43 changes: 43 additions & 0 deletions .github/workflows/webr.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: WebR compatibility

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
webr:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7

- name: Pin WebR 0.6.0 builder with the upstream resolver patch
run: docker build tests/webr -t myio-webr-builder:v0.6.0

- name: Build myIO and dependencies for WebAssembly
uses: r-wasm/actions/build-rwasm@v3
with:
packages: "local::."
repo-path: _webr-repo
image-path: _webr-image
webr-image: myio-webr-builder:v0.6.0

- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
cache-dependency-path: tests/webr/package-lock.json

- name: Install WebR harness dependencies
run: npm ci --prefix tests/webr

- name: Install Chromium
run: npx --prefix tests/webr playwright install --with-deps chromium

- name: Verify R to browser rendering
run: node tests/webr/verify.mjs _webr-repo
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ inst/myIOsticker.png
/pkgdown
coverage/
/.claude
/.codex/
.mcp.json
/.playwright-mcp
/test-results
Expand Down
3 changes: 3 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

S3method(print,myIO_duckdb_wasm_status)
export(addIoLayer)
export(addKeyframe)
export(clear_duckdb_wasm_cache)
export(defineCategoricalAxis)
export(dragPoints)
Expand All @@ -28,6 +29,7 @@ export(setBrush)
export(setColorScheme)
export(setExportOptions)
export(setFacet)
export(setKeyframe)
export(setLayerOpacity)
export(setLinked)
export(setLinkedCursor)
Expand All @@ -40,6 +42,7 @@ export(setToggle)
export(setToolTipOptions)
export(setTransition)
export(setTransitionSpeed)
export(stepKeyframe)
export(stop_duckdb_wasm_missing)
export(suppressAxis)
export(suppressLegend)
Expand Down
18 changes: 18 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

## New features

* Keyframe storytelling adds `addKeyframe()` for named, transformed data
snapshots and accessible previous/play-pause/next controls. Single-layer
charts accept a data frame; multi-layer charts accept a named list keyed by
layer label, with omitted layers retaining their prior state. Playback runs
once and stops at the final frame, while reduced-motion and zero-duration
transitions remain fully step- and play-capable. Shiny applications can use
`setKeyframe()` and `stepKeyframe()` through the existing instance registry.
* WebR 0.6.0 compatibility is now a blocking CI contract: the package and its
dependencies are compiled with the official r-wasm action, loaded in WebR,
used to create and serialize a real widget, and rendered with the production
bundle in Chromium. The verified path does not claim DuckDB-WASM support or
universal compatibility across browser hosts.

* Legend/button UI streamlining (#84): charts now show exactly one legend
surface at a time. When a discrete chart's compact in-plot legend is showing,
the chart-controls panel no longer repeats the same legend and becomes
Expand Down Expand Up @@ -46,6 +59,11 @@

## Performance and tooling

* Release dependency intake updates the GitHub Actions, browser-test, Arrow,
MCP, and JavaScript security transitive dependencies through PRs #91--#100.
The MCP server now resolves `@hono/node-server` 2.0.12 and declares Node.js
20 or newer as its runtime floor; its conformance, stdio smoke, and audit
gates pass with zero known npm vulnerabilities.
* The production JavaScript bundle is now minified. The shipped
`inst/htmlwidgets/myIO/myIOapi.js` drops from 2.32 MB to 1.20 MB raw
(398,650 to 298,757 bytes gzipped, -25%) with no behavior change; the
Expand Down
160 changes: 160 additions & 0 deletions R/addKeyframe.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
#' Add a Named Data Keyframe
#'
#' Registers a named data state for sequential chart storytelling. A chart with
#' one serialized layer accepts a data frame directly. Multi-layer charts use a
#' named list of data frames keyed by existing layer labels; omitted layers
#' retain their data from the previous keyframe.
#'
#' @param myIO A widget created by \code{\link{myIO}()} with at least one layer.
#' @param data A data frame for a single-layer chart, or a named list of data
#' frames keyed by layer label for a multi-layer chart.
#' @param label A unique, non-empty keyframe label.
#' @return A modified \code{myIO} widget with the keyframe appended.
#' @examples
#' start <- data.frame(x = 1:3, y = c(2, 4, 3))
#' finish <- data.frame(x = 1:3, y = c(5, 3, 7))
#' myIO(start) |>
#' addIoLayer("line", label = "series",
#' mapping = list(x_var = "x", y_var = "y")) |>
#' addKeyframe(start, "Start") |>
#' addKeyframe(finish, "Finish")
#' @export
addKeyframe <- function(myIO, data, label) {
assert_myIO(myIO)
layers <- myIO$x$config$layers
if (length(layers) == 0L) {
stop("addKeyframe(): the chart must have at least one layer.", call. = FALSE)
}
if (!is.character(label) || length(label) != 1L || is.na(label) ||
!nzchar(trimws(label))) {
stop("addKeyframe(): label must be a single non-empty string.", call. = FALSE)
}

keyframes <- myIO$x$config$keyframes
if (is.null(keyframes)) keyframes <- list()
existing_labels <- vapply(keyframes, function(frame) frame[["label"]], character(1))
if (label %in% existing_labels) {
stop("addKeyframe(): label must be unique; '", label, "' already exists.",
call. = FALSE)
}

layer_labels <- vapply(layers, function(layer) layer[["label"]], character(1))
if (is.data.frame(data)) {
if (length(layers) != 1L) {
stop("addKeyframe(): multi-layer charts require a named list of data frames.",
call. = FALSE)
}
updates <- stats::setNames(list(data), layer_labels[[1]])
} else if (is.list(data)) {
update_names <- names(data)
if (length(data) == 0L || is.null(update_names) ||
any(is.na(update_names)) || any(!nzchar(update_names))) {
stop("addKeyframe(): data must be a non-empty named list keyed by layer label.",
call. = FALSE)
}
if (anyDuplicated(update_names)) {
stop("addKeyframe(): layer names in data must be unique.", call. = FALSE)
}
unknown <- setdiff(update_names, layer_labels)
if (length(unknown) > 0L) {
stop("addKeyframe(): unknown layer label(s): ", paste(unknown, collapse = ", "),
".", call. = FALSE)
}
invalid <- update_names[!vapply(data, is.data.frame, logical(1))]
if (length(invalid) > 0L) {
stop("addKeyframe(): data for layer '", invalid[[1]], "' must be a data frame.",
call. = FALSE)
}
updates <- data
} else {
stop("addKeyframe(): data must be a data frame or named list of data frames.",
call. = FALSE)
}

prior_layers <- if (length(keyframes) > 0L) keyframes[[length(keyframes)]]$layers else
lapply(layers, function(layer) list(label = layer$label, data = layer$data))
prior_by_label <- stats::setNames(
prior_layers,
vapply(prior_layers, function(layer) layer[["label"]], character(1))
)
snapshot <- lapply(seq_along(layers), function(index) {
layer <- layers[[index]]
layer_label <- layer$label
if (layer_label %in% names(updates)) {
list(label = layer_label, data = serialize_keyframe_data(layer, updates[[layer_label]]))
} else {
list(label = layer_label, data = prior_by_label[[layer_label]]$data)
}
})

myIO$x$config$keyframes <- c(keyframes, list(list(label = label, layers = snapshot)))
myIO
}

serialize_keyframe_data <- function(layer, data) {
data <- ensure_source_key(data)
transform <- if (is.null(layer$transform)) "identity" else layer$transform
transformed <- get_transform(transform)(data, layer$mapping, layer$options)
transformed_data <- transformed$data
if (identical(layer$type, "treemap")) {
return(build_tree(transformed_data, layer$label,
layer$mapping$level_1, layer$mapping$level_2))
}
as_layer_rows(transformed_data)
}

#' Control Keyframes in Shiny
#'
#' Select a named or numbered keyframe, or step an existing myIO widget without
#' re-rendering the widget.
#'
#' @param proxy A \code{myIO_proxy} object returned by \code{\link{myIOProxy}()}.
#' @param frame A unique keyframe label or positive one-based keyframe index.
#' @param direction Either \code{"next"} or \code{"previous"}.
#' @return The proxy, invisibly.
#' @examples
#' \dontrun{
#' myIOProxy("chart") |> setKeyframe("Forecast")
#' myIOProxy("chart") |> stepKeyframe("next")
#' }
#' @export
setKeyframe <- function(proxy, frame) {
assert_keyframe_proxy(proxy, "setKeyframe")
valid_character <- is.character(frame) && length(frame) == 1L &&
!is.na(frame) && nzchar(trimws(frame))
valid_numeric <- is.numeric(frame) && length(frame) == 1L && !is.na(frame) &&
is.finite(frame) && frame >= 1 && frame <= .Machine$integer.max &&
frame == floor(frame)
if (!valid_character && !valid_numeric) {
if (is.numeric(frame) && length(frame) == 1L && !is.na(frame) && frame < 1) {
stop("setKeyframe(): numeric frame must be a positive one-based index.",
call. = FALSE)
}
stop("setKeyframe(): frame must be a single non-empty label or positive one-based index.",
call. = FALSE)
}
if (valid_numeric) frame <- as.integer(frame)
proxy$session$sendCustomMessage(
"myio:keyframe-control",
list(id = proxy$id, action = "select", frame = frame)
)
invisible(proxy)
}

#' @rdname setKeyframe
#' @export
stepKeyframe <- function(proxy, direction = c("next", "previous")) {
assert_keyframe_proxy(proxy, "stepKeyframe")
direction <- match.arg(direction)
proxy$session$sendCustomMessage(
"myio:keyframe-control",
list(id = proxy$id, action = "step", direction = direction)
)
invisible(proxy)
}

assert_keyframe_proxy <- function(proxy, caller) {
if (!inherits(proxy, "myIO_proxy")) {
stop(caller, "(): proxy must be a myIOProxy() object.", call. = FALSE)
}
}
21 changes: 18 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
[![R-CMD-check](https://github.com/mortonanalytics/myIO/actions/workflows/R-CMD-check.yaml/badge.svg)](https://github.com/mortonanalytics/myIO/actions/workflows/R-CMD-check.yaml)
[![WebR compatibility](https://github.com/mortonanalytics/myIO/actions/workflows/webr.yaml/badge.svg)](https://github.com/mortonanalytics/myIO/actions/workflows/webr.yaml)
![R coverage](man/figures/coverage-badge.svg)
![JS coverage](man/figures/js-coverage-badge.svg)
[![Lifecycle: stable](https://img.shields.io/badge/lifecycle-stable-brightgreen.svg)](https://lifecycle.r-lib.org/articles/stages.html#stable)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
![version](https://img.shields.io/badge/version-1.0.0-blue)
![version](https://img.shields.io/badge/version-1.3.0-blue)

# myIO

An R package for creating interactive `d3.js` visualizations using `htmlwidgets`. Supports 17 chart types including scatter plots, line charts, bar charts, treemaps, and more — all composable through a piped API.
An R package for creating interactive `d3.js` visualizations using `htmlwidgets`. Supports 36 chart types including scatter plots, line charts, uncertainty views, statistical composites, and more — all composable through a piped API.

[Live Demo](https://mortonanalytics.github.io/myIO/)

Expand Down Expand Up @@ -84,6 +85,19 @@ myIO charts are bidirectional — user actions flow back as structured data:
- `setAnnotation()` — Click to label data points; export annotations as CSV
- `setLinked()` — Crosstalk linked brushing across multiple charts
- `setSlider()` — Parameter sliders that trigger Shiny recomputation
- `addKeyframe()` — Register complete data snapshots for sequential storytelling
- `setKeyframe()` / `stepKeyframe()` — Select or step keyframes through a Shiny proxy

## Runtime Compatibility

| Runtime | Supported path |
|---------|----------------|
| RStudio, R Markdown, and Quarto | Standard `htmlwidgets` rendering |
| Shiny | Widget rendering, reactive inputs, proxy data updates, and keyframe control |
| WebR 0.6.0 | Precompiled Wasm package, R payload creation, and production-bundle rendering in Chromium |

The WebR claim is intentionally bounded to the pinned end-to-end CI path; it
does not imply that DuckDB-WASM or every browser host has been validated.

## Customization

Expand All @@ -96,6 +110,7 @@ Customize plots by chaining additional functions:
- `setColorScheme()` — Apply a custom color palette
- `setTheme()` — Set theme tokens (colors, font, background)
- `setTransitionSpeed()` — Control animation duration
- `setTransition()` — Configure duration, easing, and stagger
- `setToolTipOptions()` — Configure tooltip behavior
- `setToggle()` — Enable layer toggle controls
- `flipAxis()` — Swap x and y axes
Expand All @@ -104,4 +119,4 @@ Customize plots by chaining additional functions:
- `dragPoints()` — Enable draggable points
- `setReferenceLines()` — Add reference lines

See the [Getting Started](https://mortonanalytics.github.io/myIO/articles/getting-started.html), [Chart Types](https://mortonanalytics.github.io/myIO/articles/chart-types.html), [Shiny Integration](https://mortonanalytics.github.io/myIO/articles/shiny-integration.html), and [Transforms & Theming](https://mortonanalytics.github.io/myIO/articles/transforms-and-theming.html) vignettes for full examples.
See the [Getting Started](https://mortonanalytics.github.io/myIO/articles/getting-started.html), [Chart Types](https://mortonanalytics.github.io/myIO/articles/chart-types.html), [Sequential Storytelling](https://mortonanalytics.github.io/myIO/articles/sequential-storytelling.html), [Shiny Integration](https://mortonanalytics.github.io/myIO/articles/shiny-integration.html), and [Transforms & Theming](https://mortonanalytics.github.io/myIO/articles/transforms-and-theming.html) articles for full examples.
5 changes: 5 additions & 0 deletions _pkgdown.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,13 @@ articles:
contents:
- shiny-integration
- articles/shiny-interactions
- articles/sequential-storytelling
- title: Big-data & Linking
contents:
- large-data-linking
- articles/crosstalk-linking
- sparklines
- articles/webr-quarto-live
- title: Migration
contents:
- plotly-migration
Expand All @@ -78,6 +80,7 @@ reference:
contents:
- myIO
- addIoLayer
- addKeyframe
- title: Axes & Scales
desc: Control axis formatting, limits, and orientation
contents:
Expand Down Expand Up @@ -127,6 +130,8 @@ reference:
contents:
- starts_with("myIO-shiny")
- myIOProxy
- setKeyframe
- stepKeyframe
- title: LLM Tool Calling
desc: Machine-readable schema and validators for agent-built chart specs
contents:
Expand Down
Loading
Loading