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
2 changes: 2 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export(install_duckdb_wasm)
export(linkCharts)
export(myIO)
export(myIOOutput)
export(myIOProxy)
export(myIO_last_error)
export(myio_chart_schema)
export(myio_function_signature)
Expand Down Expand Up @@ -42,6 +43,7 @@ export(setTransitionSpeed)
export(stop_duckdb_wasm_missing)
export(suppressAxis)
export(suppressLegend)
export(updateMyIOData)
importFrom(htmlwidgets,createWidget)
importFrom(htmlwidgets,shinyRenderWidget)
importFrom(htmlwidgets,shinyWidgetOutput)
Expand Down
6 changes: 6 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@
`addIoLayer(type = "line", transform = "lttb", options = list(threshold = 1000))`.
Off by default (`identity`); runs on the in-memory/SVG path and is independent
of the DuckDB-WASM engine's own SQL-side LTTB, so it never double-downsamples.
* New `myIOProxy()` + `updateMyIOData()` update a rendered chart's layer data in
place from the Shiny server without re-running `renderMyIO()`. Layers are
matched by label and swapped through the existing data-join path, so only the
changed marks transition and brush/zoom/toggle state is preserved (the full
re-render destroyed and recreated the chart, flickering and dropping state):
`myIOProxy("chart") |> updateMyIOData(series = new_df)`.

## Performance and tooling

Expand Down
88 changes: 88 additions & 0 deletions R/myIOProxy.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#' Update a myIO chart in place from the Shiny server
#'
#' `myIOProxy()` creates a lightweight handle to an already-rendered myIO widget,
#' and `updateMyIOData()` swaps the data of one or more existing layers without
#' re-rendering the whole widget. Unlike re-executing `renderMyIO()` (which
#' destroys and recreates the chart on every reactive invalidation, dropping
#' brush/zoom/toggle state and flickering), a proxy update reuses the existing
#' data-join path: only the changed marks transition, and interaction state is
#' preserved.
#'
#' Layers are matched by their `label`. Unknown labels are ignored client-side.
#' The supplied data frame replaces the layer's data as-is (the identity data
#' path); statistical transforms attached at `addIoLayer()` time are not
#' re-applied, so pass already-transformed data for transformed layers.
#'
#' @param outputId The output id of the `myIOOutput()` whose chart to update.
#' @param session The Shiny session object. Defaults to the current reactive
#' domain.
#' @param proxy A `myIO_proxy` object from `myIOProxy()`.
#' @param ... One or more `label = data.frame` updates, where `label` is an
#' existing layer label and the data frame carries that layer's mapped columns.
#'
#' @return `myIOProxy()` returns a `myIO_proxy` object; `updateMyIOData()`
#' returns the proxy invisibly.
#'
#' @examples
#' \dontrun{
#' library(shiny)
#' ui <- fluidPage(myIOOutput("chart"), actionButton("go", "Resample"))
#' server <- function(input, output, session) {
#' output$chart <- renderMyIO({
#' myIO(data = data.frame(x = 1:50, y = rnorm(50))) |>
#' addIoLayer("line", label = "series", mapping = list(x_var = "x", y_var = "y"))
#' })
#' observeEvent(input$go, {
#' myIOProxy("chart") |>
#' updateMyIOData(series = data.frame(x = 1:50, y = rnorm(50)))
#' })
#' }
#' shinyApp(ui, server)
#' }
#'
#' @export
myIOProxy <- function(outputId, session = NULL) {
if (!requireNamespace("shiny", quietly = TRUE)) {
stop("myIOProxy() requires the 'shiny' package.", call. = FALSE)
}
check_string(outputId, "outputId", "myIOProxy")
if (is.null(session)) {
session <- shiny::getDefaultReactiveDomain()
}
if (is.null(session)) {
stop("myIOProxy() must be called from within a Shiny session.", call. = FALSE)
}
structure(
list(id = session$ns(outputId), session = session),
class = "myIO_proxy"
)
}

#' @rdname myIOProxy
#' @export
updateMyIOData <- function(proxy, ...) {
if (!inherits(proxy, "myIO_proxy")) {
stop("updateMyIOData(): `proxy` must be a myIOProxy() object.", call. = FALSE)
}
updates <- list(...)
labels <- names(updates)
if (length(updates) == 0L || is.null(labels) || any(labels == "")) {
stop("updateMyIOData(): supply one or more named `label = data.frame` updates.",
call. = FALSE)
}

layers <- lapply(labels, function(label) {
data <- updates[[label]]
if (!is.data.frame(data)) {
stop("updateMyIOData(): update for layer '", label,
"' must be a data frame.", call. = FALSE)
}
list(label = label, data = as_layer_rows(ensure_source_key(data)))
})

proxy$session$sendCustomMessage(
"myio:proxy-update",
list(id = proxy$id, layers = layers)
)
invisible(proxy)
}
1 change: 1 addition & 0 deletions _pkgdown.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ reference:
desc: Use myIO widgets in Shiny applications
contents:
- starts_with("myIO-shiny")
- myIOProxy
- title: LLM Tool Calling
desc: Machine-readable schema and validators for agent-built chart specs
contents:
Expand Down
18 changes: 17 additions & 1 deletion inst/htmlwidgets/myIO.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ HTMLWidgets.widget({
var coordinatorMarkSpec = null;
var coordinatorQueryTemplate = "";
if (this.myIOchart) {
// Destroy and recreate to handle layer count/type changes cleanly
// Destroy and recreate to handle layer count/type changes cleanly.
// The chart's "destroy" event unregisters it from the proxy registry.
this.myIOchart.destroy();
d3.select(el).selectAll("*").remove();
this.myIOchart = null;
Expand Down Expand Up @@ -115,6 +116,21 @@ HTMLWidgets.widget({
height: height
});
var id = el.id;
// Register for myIOProxy() partial updates immediately after
// construction (before any async coordinator work) so the registry
// never has an empty window across a re-render where an in-flight
// proxy message could be dropped. The chart's "destroy" event reaps
// the entry on re-render and on teardown.
if (HTMLWidgets.shinyMode && window.myIO &&
typeof window.myIO.registerInstance === "function") {
window.myIO.registerInstance(id, this.myIOchart);
window.myIO.installProxyHandler();
this.myIOchart.on("destroy", function() {
if (window.myIO && typeof window.myIO.unregisterInstance === "function") {
window.myIO.unregisterInstance(id);
}
});
}
this.myIOchart.on("error", function(e) {
el._myIO_lastError = {
message: e.message,
Expand Down
4 changes: 2 additions & 2 deletions inst/htmlwidgets/myIO/myIOapi.js

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions inst/htmlwidgets/myIO/src/Chart.js
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,34 @@ export class myIOchart {
this.renderCurrentLayers();
}

// Shiny proxy partial-update (myIOProxy): swap the data of existing layers by
// label and re-render through the same data-join path as a normal update.
// Unlike a full renderValue (which destroys + recreates the chart), this
// preserves brush/zoom/toggle state and animates the transition. Unknown
// labels are ignored; `updates` is [{ label, data }].
updateData(updates) {
if (!Array.isArray(updates) || !this.config || !Array.isArray(this.config.layers)) {
return;
}
// Null-prototype map so a layer label like "__proto__"/"constructor" cannot
// pollute Object.prototype, and an incoming update.label of "__proto__"
// cannot spoof a match; the hasOwnProperty guard reinforces this.
const byLabel = Object.create(null);
this.config.layers.forEach(function(layer) { byLabel[layer.label] = layer; });
updates.forEach(function(update) {
if (update &&
Object.prototype.hasOwnProperty.call(byLabel, update.label) &&
Array.isArray(update.data)) {
byLabel[update.label].data = update.data;
}
});
// Mutating the shared layer objects updates whatever subset is currently
// visible (derived.currentLayers references the same objects), so we do NOT
// reset currentLayers here — that would re-show legend-toggled-off layers.
this.syncLegacyAliases();
this.renderCurrentLayers();
}

resize(width, height) {
if (!width || !height || width < 2 || height < 2) return;
const wasSheetOpen = this.runtime && this.runtime._sheetOpen === true;
Expand Down
33 changes: 33 additions & 0 deletions inst/htmlwidgets/myIO/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,39 @@ if (typeof window !== "undefined") {
window.myIO.normalizeCoordinatorBatches = normalizeCoordinatorBatches;
window.myIO.isWebGLEligible = isWebGLEligible;
window.myIO.getCoordinator = function() { return globalThis.__myioCoordinator || null; };

// myIOProxy() partial-update support. The htmlwidget binding registers each
// live chart here by outputId; a single Shiny custom-message handler routes
// R-side myIOProxy() data swaps to the matching chart's updateData().
// Null-prototype map: keys are untrusted outputIds, so a key of "__proto__"
// must not reach Object.prototype.
window.myIO._instances = window.myIO._instances || Object.create(null);
window.myIO.registerInstance = function(id, chart) {
if (id) window.myIO._instances[id] = chart;
};
window.myIO.unregisterInstance = function(id) {
if (id && window.myIO._instances) delete window.myIO._instances[id];
};
window.myIO.installProxyHandler = function() {
if (window.myIO._proxyHandlerInstalled) return;
if (!window.Shiny || typeof window.Shiny.addCustomMessageHandler !== "function") return;
window.Shiny.addCustomMessageHandler("myio:proxy-update", function(msg) {
if (!msg || !msg.id) return;
var chart = window.myIO._instances[msg.id];
if (!chart) return;
// Lazily reap a destroyed chart whose binding was removed from the DOM
// without a re-render (e.g. conditionalPanel/renderUI -> NULL); destroy()
// nulls config.
if (!chart.config) {
window.myIO.unregisterInstance(msg.id);
return;
}
if (typeof chart.updateData === "function") {
chart.updateData(msg.layers || []);
}
});
window.myIO._proxyHandlerInstalled = true;
};
window.myIO.webglRenderers = {
createWebGLRenderer,
WebGLScatter,
Expand Down
8 changes: 8 additions & 0 deletions inst/myio-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1315,6 +1315,10 @@
"width",
"height"
],
"myIOProxy": [
"outputId",
"session"
],
"print.myIO_duckdb_wasm_status": [
"x",
"..."
Expand Down Expand Up @@ -1463,6 +1467,10 @@
"suppressLegend": [
"myIO",
"suppressLegend"
],
"updateMyIOData": [
"proxy",
"..."
]
}
}
59 changes: 59 additions & 0 deletions man/myIOProxy.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions mcp/myio-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1315,6 +1315,10 @@
"width",
"height"
],
"myIOProxy": [
"outputId",
"session"
],
"print.myIO_duckdb_wasm_status": [
"x",
"..."
Expand Down Expand Up @@ -1463,6 +1467,10 @@
"suppressLegend": [
"myIO",
"suppressLegend"
],
"updateMyIOData": [
"proxy",
"..."
]
}
}
Loading
Loading