From 79fe996e3fed9c290266d72cf93105b2b35874a4 Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Thu, 26 Jun 2025 17:09:42 -0700
Subject: [PATCH 01/29] first stab

---
 R/by_aesthetics.R   | 40 ++++++++++++++++++++++++++++++++++++++++
 R/draw_legend.R     |  6 ++++--
 R/tinyplot.R        | 45 +++++++++++++++++++++++++++++++++++++++++++--
 R/type_errorbar.R   |  4 ++--
 R/type_lines.R      |  4 ++--
 R/type_pointrange.R |  4 ++--
 R/type_points.R     | 26 +++++++++++++++++++-------
 R/type_qq.R         |  4 ++--
 R/type_text.R       |  4 ++--
 9 files changed, 116 insertions(+), 21 deletions(-)

diff --git a/R/by_aesthetics.R b/R/by_aesthetics.R
index 38bf7fe4..4a28018d 100755
--- a/R/by_aesthetics.R
+++ b/R/by_aesthetics.R
@@ -394,6 +394,46 @@ by_lwd = function(ngrps, type, lwd = NULL) {
 }
 
 
+by_cex = function(ngrps, type, bubble = FALSE, cex = NULL) {
+  # browser()
+  # cex_base = par("cex")
+  # lwd_floor = lwd_base / min(5, max((ngrps - 1), 1))
+  # lwd_ceiling = lwd_base * min(5, ngrps)
+
+  no_cex = FALSE
+  # special "by" convenience keyword
+  if (!is.null(cex) && length(cex) == 1 && cex == "by") {
+    no_cex = TRUE # skip checks below
+    cex = rescale_num(c(1:ngrps), to = c(1, 2.5))
+  } else if (is.null(cex)) {
+    no_cex = TRUE
+    # cex = NULL
+    # can't leave cex as NULL otherwise JIT cex_fct_adj adjustment in
+    # draw_legend() won't work later 
+    cex = 1#par("cex")
+    cex = rep(cex, ngrps)
+  }
+
+  # placehodler
+  if (bubble) no_cex = TRUE
+
+  if (!no_cex) {
+    # if (bubble) {
+    #   cex = rescale_num(cex, to = c(1, 2.5))
+    # } else if (!is.atomic(cex) || !is.vector(cex) || !is.numeric(cex) || (length(cex) != 1 && length(cex) != ngrps)) {
+    if (!is.atomic(cex) || !is.vector(cex) || !is.numeric(cex) || (length(cex) != 1 && length(cex) != ngrps)) {
+      stop(sprintf("`cex` must either be `NULL`, or a numeric vector of length 1 or %s (no. of groups).", ngrps), call. = FALSE)
+    }
+    if (length(cex) == 1) {
+      cex = rep(cex, ngrps)
+    }
+  }
+
+  return(cex)
+}
+
+
+
 by_bg = function(
     adjustcolor,
     alpha,
diff --git a/R/draw_legend.R b/R/draw_legend.R
index ce960e6b..39f45cab 100644
--- a/R/draw_legend.R
+++ b/R/draw_legend.R
@@ -168,8 +168,10 @@ draw_legend = function(
       legend_args[["lwd"]] = legend_args[["lwd"]] %||% lwd
     }
   
-    if (isTRUE(type %in% c("p", "pointrange", "errorbar")) && (length(col) == 1 || length(cex) == 1)) {
-      legend_args[["pt.cex"]] = legend_args[["pt.cex"]] %||% cex
+    # browser()
+    # if (isTRUE(type %in% c("p", "pointrange", "errorbar")) && (length(col) == 1 || length(cex) == 1)) {
+    if (type %in% c("p", "pointrange", "errorbar")) {
+      legend_args[["pt.cex"]] = legend_args[["pt.cex"]] %||% (cex %||% par("cex"))
     }
   
     # turn off inner line for "barplot" type
diff --git a/R/tinyplot.R b/R/tinyplot.R
index 27954b73..dce6ffe4 100644
--- a/R/tinyplot.R
+++ b/R/tinyplot.R
@@ -592,7 +592,7 @@ tinyplot.default = function(
     bg = NULL,
     fill = NULL,
     alpha = NULL,
-    cex = 1,
+    cex = NULL,
     add = FALSE,
     draw = NULL,
     empty = FALSE,
@@ -620,6 +620,7 @@ tinyplot.default = function(
     # assign(".last_call", cal, envir = get(".tinyplot_env", envir = parent.env(environment())))
   }
 
+  # browser()
   dots = list(...)
 
   if (add) legend = FALSE
@@ -781,6 +782,9 @@ tinyplot.default = function(
   # alias
   if (is.null(bg) && !is.null(fill)) bg = fill
 
+  # TEST bubble
+  bubble = FALSE
+
   datapoints = list(x = x, y = y, xmin = xmin, xmax = xmax, ymin = ymin, ymax = ymax, ygroup = ygroup)
   datapoints = Filter(function(z) length(z) > 0, datapoints)
   datapoints = data.frame(datapoints)
@@ -803,6 +807,7 @@ tinyplot.default = function(
       log          = log,
       lty          = lty,
       lwd          = lwd,
+      cex          = cex,
       facet        = facet,
       facet_by     = facet_by,
       facet.args   = facet.args,
@@ -928,6 +933,11 @@ tinyplot.default = function(
   pch = by_pch(ngrps = ngrps, type = type, pch = pch)
   lty = by_lty(ngrps = ngrps, type = type, lty = lty)
   lwd = by_lwd(ngrps = ngrps, type = type, lwd = lwd)
+  ## TEST
+  # browser()
+  cex = by_cex(ngrps = ngrps, type = type, bubble = bubble, cex = cex)
+  if (bubble) split_data[[1]][["cex"]] = cex ## check (maybe ngrps!=length(cex)?)
+  ## END TEST
   col = by_col(
     ngrps = ngrps, col = col, palette = palette,
     gradient = by_continuous, ordered = by_ordered, alpha = alpha)
@@ -1011,6 +1021,23 @@ tinyplot.default = function(
       legend_args[["lty"]] = 0
     }
 
+    # browser()
+    # l = draw_legend(
+    #   legend = legend,
+    #   legend_args = legend_args,
+    #   by_dep = by_dep,
+    #   lgnd_labs = lgnd_labs,
+    #   type = type,
+    #   pch = pch,
+    #   lty = lty,
+    #   lwd = lwd,
+    #   col = col,
+    #   bg = bg,
+    #   gradient = by_continuous,
+    #   cex = cex * cex_fct_adj,
+    #   has_sub = has_sub,
+    #   draw = FALSE
+    # )
     draw_legend(
       legend = legend,
       legend_args = legend_args,
@@ -1239,6 +1266,7 @@ tinyplot.default = function(
 
   ## Outer loop over the facets
   for (i in seq_along(split_data)) {
+    # browser()
     # Split group-level data again to grab any "by" groups
     idata = split_data[[i]]
     iby = idata[["by"]]
@@ -1246,6 +1274,15 @@ tinyplot.default = function(
       if (isTRUE(by_continuous)) {
         idata[["col"]] = col[round(rescale_num(idata$by, from = range(datapoints$by), to = c(1, 100)))]
         idata[["bg"]] = bg[round(rescale_num(idata$by, from = range(datapoints$by), to = c(1, 100)))]
+        # ## TEST
+        # if (cex_continuous) {
+        #   if (cex_by) { ## NEED TO CONSTRUCT ABOVE
+        #     idata[["cex"]] = cex[round(rescale_num(idata$by, from = range(datapoints$by), to = c(1, 2.5)))]
+        #   } else { ## WHAT SHOULD THIS BE??
+        #     idata[["cex"]] = cex[round(rescale_num(idata$by, from = range(datapoints$by), to = c(1, 2.5)))]
+        #   }
+        # }
+        # ## END TEST
         idata = list(idata)
       } else {
         idata = lapply(idata, split, iby)
@@ -1307,6 +1344,9 @@ tinyplot.default = function(
         ibg = idata[[ii]]$bg
       }
 
+      # browser() ## bubble test
+      icex = if (bubble) idata[[ii]][["cex"]] else cex[[ii]]
+
       # empty plot flag
       empty_plot = FALSE
       if (isTRUE(empty) || isTRUE(type == "n") || ((length(ix) == 0) && !(type %in% c("histogram", "hist", "rect", "segments", "spineplot")))) {
@@ -1345,7 +1385,8 @@ tinyplot.default = function(
           iymin = iymin,
           ilabels = ilabels,
           iz = iz,
-          cex = cex,
+          # cex = cex,
+          icex = icex,
           dots = dots,
           type = type,
           x_by = x_by,
diff --git a/R/type_errorbar.R b/R/type_errorbar.R
index 1fd252f3..190e32cc 100644
--- a/R/type_errorbar.R
+++ b/R/type_errorbar.R
@@ -32,7 +32,7 @@ type_errorbar = function(length = 0.05) {
 
 
 draw_errorbar = function(length = 0.05) {
-    fun = function(ix, iy, ixmin, iymin, ixmax, iymax, icol, ibg, ipch, ilwd, cex, ...) {
+    fun = function(ix, iy, ixmin, iymin, ixmax, iymax, icol, ibg, ipch, ilwd, icex, ...) {
         arrows(
             x0 = ixmin,
             y0 = iymin,
@@ -44,7 +44,7 @@ draw_errorbar = function(length = 0.05) {
             angle = 90,
             code = 3
         )
-        draw_points()(ix = ix, iy = iy, icol = icol, ibg = ibg, ipch = ipch, ilwd = ilwd, cex = cex)
+        draw_points()(ix = ix, iy = iy, icol = icol, ibg = ibg, ipch = ipch, ilwd = ilwd, icex = icex)
     }
     return(fun)
 }
diff --git a/R/type_lines.R b/R/type_lines.R
index 613c5342..eeffcc1c 100644
--- a/R/type_lines.R
+++ b/R/type_lines.R
@@ -24,7 +24,7 @@ type_lines = function(type = "l") {
 
 
 draw_lines = function(type = "l") {
-    fun = function(ix, iy, icol, ipch, ibg, ilty, ilwd, cex = 1, ...) {
+    fun = function(ix, iy, icol, ipch, ibg, ilty, ilwd, icex = 1, ...) {
         lines(
             x = ix,
             y = iy,
@@ -34,7 +34,7 @@ draw_lines = function(type = "l") {
             bg = ibg,
             lty = ilty,
             lwd = ilwd,
-            cex = cex
+            cex = icex
         )
     }
     return(fun)
diff --git a/R/type_pointrange.R b/R/type_pointrange.R
index 7e252822..51f19fa6 100644
--- a/R/type_pointrange.R
+++ b/R/type_pointrange.R
@@ -13,7 +13,7 @@ type_pointrange = function() {
 
 
 draw_pointrange = function() {
-    fun = function(ix, iy, ixmin, iymin, ixmax, iymax, icol, ibg, ipch, ilwd, cex, ...) {
+    fun = function(ix, iy, ixmin, iymin, ixmax, iymax, icol, ibg, ipch, ilwd, icex, ...) {
         segments(
             x0 = ixmin,
             y0 = iymin,
@@ -22,7 +22,7 @@ draw_pointrange = function() {
             col = icol,
             lwd = ilwd
         )
-        draw_points()(ix = ix, iy = iy, icol = icol, ibg = ibg, ipch = ipch, ilwd = ilwd, cex = cex)
+        draw_points()(ix = ix, iy = iy, icol = icol, ibg = ibg, ipch = ipch, ilwd = ilwd, icex = icex)
     }
     return(fun)
 }
diff --git a/R/type_points.R b/R/type_points.R
index 7c488473..5109b652 100644
--- a/R/type_points.R
+++ b/R/type_points.R
@@ -1,6 +1,8 @@
 #' Points plot type
 #'
 #' @description Type function for plotting points, i.e. a scatter plot.
+#' @param clim Numeric giving the lower and upper limits of the character
+#'   expansion normalization for bubble charts.
 #' 
 #' @examples
 #' # "p" type convenience character string
@@ -20,18 +22,18 @@
 #' tinyplot(Sepal.Length ~ Petal.Length | Sepal.Width, data = iris, pch = 19)
 #' 
 #' @export
-type_points = function() {
+type_points = function(clim = c(0.5, 2.5)) {
   out = list(
+    data = data_points(clim = clim),
     draw = draw_points(),
-    data = data_points(),
     name = "p"
   )
   class(out) = "tinyplot_type"
   return(out)
 }
 
-data_points = function() {
-  fun = function(datapoints, ...) {
+data_points = function(clim = c(0.5, 2.5)) {
+  fun = function(datapoints, cex = NULL, ...) {
     # catch for factors (we should still be able to "force" plot these with points)
     if (is.factor(datapoints$x)) {
       xlvls = levels(datapoints$x)
@@ -49,18 +51,28 @@ data_points = function() {
     } else {
       ylabs = NULL
     }
+
+    # browser()
+    bubble = FALSE
+    if (!is.null(cex) && length(cex) == nrow(datapoints)) {
+      cex = rescale_num(cex, to = clim) 
+      bubble = TRUE
+    }
     
     out = list(
       datapoints = datapoints,
       xlabs = xlabs,
-      ylabs = ylabs
+      ylabs = ylabs,
+      cex = cex,
+      bubble = bubble
     )
     return(out)
   }
 }
 
 draw_points = function() {
-    fun = function(ix, iy, icol, ibg, ipch, ilwd, cex, ...) {
+    fun = function(ix, iy, icol, ibg, ipch, ilwd, icex, ...) {
+    # browser()
         points(
             x = ix,
             y = iy,
@@ -69,7 +81,7 @@ draw_points = function() {
             type = "p",
             pch = ipch,
             lwd = ilwd,
-            cex = cex
+            cex = icex
         )
     }
     return(fun)
diff --git a/R/type_qq.R b/R/type_qq.R
index a4b61a40..cbbbb5ae 100644
--- a/R/type_qq.R
+++ b/R/type_qq.R
@@ -25,7 +25,7 @@ type_qq = function(distribution = qnorm) {
   }
 
   draw_qq = function() {
-    fun = function(ix, iy, icol, ibg, ipch, ilwd, ilty, cex, xlab, ...) {
+    fun = function(ix, iy, icol, ibg, ipch, ilwd, ilty, icex, xlab, ...) {
       points(
         x = ix,
         y = iy,
@@ -34,7 +34,7 @@ type_qq = function(distribution = qnorm) {
         type = "p",
         pch = ipch,
         lwd = ilwd,
-        cex = cex
+        cex = icex
       )
 
       if (!is.null(ilty)) {
diff --git a/R/type_text.R b/R/type_text.R
index 5206d309..db2701f6 100644
--- a/R/type_text.R
+++ b/R/type_text.R
@@ -57,14 +57,14 @@ data_text = function(labels) {
 
 draw_text = function(adj = NULL, pos = NULL, offset = 0.5, vfont = NULL, font = NULL, xpd = NULL, srt = 0) {
   if (is.null(xpd)) xpd = par("xpd")
-  fun = function(ix, iy, ilabels, icol, cex, ...) {
+  fun = function(ix, iy, ilabels, icol, icex, ...) {
     text(
       x = ix, y = iy, labels = ilabels, col = icol,
       adj = adj, pos = pos, offset = offset,
       vfont = vfont, font = font,
       xpd = xpd,
       srt = srt,
-      cex = cex
+      cex = icex
     )
   }
 }

From b9061862252c0653428f516e38c3bcbf1b88aaa9 Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Fri, 27 Jun 2025 06:45:15 -0700
Subject: [PATCH 02/29] adjust type text legend too

---
 R/draw_legend.R | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/R/draw_legend.R b/R/draw_legend.R
index 39f45cab..e7be3d42 100644
--- a/R/draw_legend.R
+++ b/R/draw_legend.R
@@ -170,7 +170,7 @@ draw_legend = function(
   
     # browser()
     # if (isTRUE(type %in% c("p", "pointrange", "errorbar")) && (length(col) == 1 || length(cex) == 1)) {
-    if (type %in% c("p", "pointrange", "errorbar")) {
+    if (type %in% c("p", "pointrange", "errorbar", "text")) {
       legend_args[["pt.cex"]] = legend_args[["pt.cex"]] %||% (cex %||% par("cex"))
     }
   

From 14bfaec33cedc4794b46a862164429bfa3f89b7a Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Sat, 28 Jun 2025 12:02:15 -0700
Subject: [PATCH 03/29] basic bubble legend

---
 R/tinyplot.R    | 83 +++++++++++++++++++++++++++++++++++--------------
 R/type_points.R | 15 ++++++---
 2 files changed, 70 insertions(+), 28 deletions(-)

diff --git a/R/tinyplot.R b/R/tinyplot.R
index dce6ffe4..7fe3df94 100644
--- a/R/tinyplot.R
+++ b/R/tinyplot.R
@@ -719,6 +719,7 @@ tinyplot.default = function(
   }
   by_dep = deparse1(substitute(by))
   null_by = is.null(by)
+  cex_dep = if (!is.null(cex)) deparse1(substitute(cex)) else NULL
 
   ## coerce character variables to factors
   if (!is.null(x) && is.character(x)) x = factor(x)
@@ -935,6 +936,7 @@ tinyplot.default = function(
   lwd = by_lwd(ngrps = ngrps, type = type, lwd = lwd)
   ## TEST
   # browser()
+  # if (bubble) bubble_labs = cex ## need to do this in type_points...
   cex = by_cex(ngrps = ngrps, type = type, bubble = bubble, cex = cex)
   if (bubble) split_data[[1]][["cex"]] = cex ## check (maybe ngrps!=length(cex)?)
   ## END TEST
@@ -967,7 +969,7 @@ tinyplot.default = function(
     pidx = round(pidx)
     lgnd_labs[pidx] = pbyvar
   }
-
+  
   # Determine the number and arrangement of facets.
   # Note: We're do this up front, so we can make some adjustments to legend cex
   #   next (if there are facets). But the actual drawing of the facets will only
@@ -981,7 +983,12 @@ tinyplot.default = function(
   #
 
   # place and draw the legend
-  has_legend = FALSE # simple indicator variable for later use
+  
+   # simple indicator variables for later use
+  # browser()
+  has_legend = FALSE
+  dual_legend = bubble && !null_by && !isFALSE(legend)
+  lgnd_cex = NULL
 
   if (!exists("legend_args")) {
     legend_args = dots[["legend_args"]]
@@ -996,17 +1003,30 @@ tinyplot.default = function(
   }
   if (!is.null(legend) && legend == "none") {
     legend_args[["x"]] = "none"
+    dual_legend = FALSE
   }
 
   if (null_by) {
     if (is.null(legend)) {
-      legend = "none"
-      legend_args[["x"]] = "none"
+      ## TEST
+      # special case: bubble legend, no by legend
+      if (bubble && !dual_legend) {
+        legend_args[["title"]] = cex_dep ## rather by_dep?
+        lgnd_labs = names(bubble_cex)
+        lgnd_cex = bubble_cex * cex_fct_adj
+      } else {
+        legend = "none"
+        legend_args[["x"]] = "none"
+      }
+      ## END TEST
+      # legend = "none"
+      # legend_args[["x"]] = "none"
     }
   }
 
-  if ((is.null(legend) || legend != "none") && !add) {
-    if (isFALSE(by_continuous)) {
+  if ((is.null(legend) || legend != "none" || bubble) && !add) {
+    # browser()
+    if (isFALSE(by_continuous) && !bubble) {
       if (ngrps > 1) {
         lgnd_labs = if (is.factor(datapoints$by)) levels(datapoints$by) else unique(datapoints$by)
       } else {
@@ -1021,23 +1041,37 @@ tinyplot.default = function(
       legend_args[["lty"]] = 0
     }
 
-    # browser()
-    # l = draw_legend(
-    #   legend = legend,
-    #   legend_args = legend_args,
-    #   by_dep = by_dep,
-    #   lgnd_labs = lgnd_labs,
-    #   type = type,
-    #   pch = pch,
-    #   lty = lty,
-    #   lwd = lwd,
-    #   col = col,
-    #   bg = bg,
-    #   gradient = by_continuous,
-    #   cex = cex * cex_fct_adj,
-    #   has_sub = has_sub,
-    #   draw = FALSE
+    if (is.null(lgnd_cex)) lgnd_cex = cex * cex_fct_adj
+
+    if (dual_legend) {
+      l = draw_legend(
+        legend = legend,
+        legend_args = legend_args,
+        by_dep = by_dep,
+        lgnd_labs = lgnd_labs,
+        type = type,
+        pch = pch,
+        lty = lty,
+        lwd = lwd,
+        col = col,
+        bg = bg,
+        gradient = by_continuous,
+        # cex = cex * cex_fct_adj,
+        cex = lgnd_cex,
+        has_sub = has_sub,
+        draw = FALSE
+      )
+    }
+    # ## MANUAL BUBBLE TEST
+    # draw_legend(
+    #   legend = "right!",
+    #   legend_args = list(title = cex_dep),
+    #   lgnd_labs = names(bubble_cex),
+    #   type = type, pch = pch,
+    #   cex = bubble_cex * cex_fct_adj,
+    #   has_sub = has_sub
     # )
+    # ## END MANUAL TEST
     draw_legend(
       legend = legend,
       legend_args = legend_args,
@@ -1050,12 +1084,13 @@ tinyplot.default = function(
       col = col,
       bg = bg,
       gradient = by_continuous,
-      cex = cex * cex_fct_adj,
+      # cex = cex * cex_fct_adj,
+      cex = lgnd_cex,
       has_sub = has_sub
     )
 
     has_legend = TRUE
-  } else if (legend_args[["x"]] == "none" && !add) {
+    } else if (legend_args[["x"]] == "none" && !add) {
     omar = par("mar")
     ooma = par("oma")
     topmar_epsilon = 0.1
diff --git a/R/type_points.R b/R/type_points.R
index 5109b652..77f45cd4 100644
--- a/R/type_points.R
+++ b/R/type_points.R
@@ -2,7 +2,7 @@
 #'
 #' @description Type function for plotting points, i.e. a scatter plot.
 #' @param clim Numeric giving the lower and upper limits of the character
-#'   expansion normalization for bubble charts.
+#'   expansion (`cex`) normalization for bubble charts.
 #' 
 #' @examples
 #' # "p" type convenience character string
@@ -54,9 +54,15 @@ data_points = function(clim = c(0.5, 2.5)) {
 
     # browser()
     bubble = FALSE
+    bubble_cex = 1
     if (!is.null(cex) && length(cex) == nrow(datapoints)) {
-      cex = rescale_num(cex, to = clim) 
-      bubble = TRUE
+      bubble = TRUE 
+      ## Identify the pretty break points for our bubble labels
+      bubble_labs = pretty(cex, n = 5)
+      cex = rescale_num(c(bubble_labs, cex), to = clim)
+      bubble_cex = cex[1:5]
+      cex = cex[(5+1):length(cex)]
+      names(bubble_cex) = format(bubble_labs)
     }
     
     out = list(
@@ -64,7 +70,8 @@ data_points = function(clim = c(0.5, 2.5)) {
       xlabs = xlabs,
       ylabs = ylabs,
       cex = cex,
-      bubble = bubble
+      bubble = bubble,
+      bubble_cex = bubble_cex
     )
     return(out)
   }

From 4ebd0f67cb122b3dfc8996646a1433f83ff33330 Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Sat, 28 Jun 2025 12:12:46 -0700
Subject: [PATCH 04/29] catch for custom legend position

---
 R/tinyplot.R | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/R/tinyplot.R b/R/tinyplot.R
index 7fe3df94..39bc4831 100644
--- a/R/tinyplot.R
+++ b/R/tinyplot.R
@@ -985,7 +985,7 @@ tinyplot.default = function(
   # place and draw the legend
   
    # simple indicator variables for later use
-  # browser()
+  browser()
   has_legend = FALSE
   dual_legend = bubble && !null_by && !isFALSE(legend)
   lgnd_cex = NULL
@@ -1009,6 +1009,7 @@ tinyplot.default = function(
   if (null_by) {
     if (is.null(legend)) {
       ## TEST
+      # browser()
       # special case: bubble legend, no by legend
       if (bubble && !dual_legend) {
         legend_args[["title"]] = cex_dep ## rather by_dep?
@@ -1021,6 +1022,10 @@ tinyplot.default = function(
       ## END TEST
       # legend = "none"
       # legend_args[["x"]] = "none"
+    } else if (bubble && !dual_legend) {
+        legend_args[["title"]] = cex_dep ## rather by_dep?
+        lgnd_labs = names(bubble_cex)
+        lgnd_cex = bubble_cex * cex_fct_adj
     }
   }
 

From 9090fa099029a8b508031d5eb9cc7c3c1d19bf5d Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Sat, 28 Jun 2025 12:30:37 -0700
Subject: [PATCH 05/29] catch for different lab breaks

---
 R/tinyplot.R    | 2 +-
 R/type_points.R | 5 +++--
 2 files changed, 4 insertions(+), 3 deletions(-)

diff --git a/R/tinyplot.R b/R/tinyplot.R
index 39bc4831..9148cf9d 100644
--- a/R/tinyplot.R
+++ b/R/tinyplot.R
@@ -985,7 +985,7 @@ tinyplot.default = function(
   # place and draw the legend
   
    # simple indicator variables for later use
-  browser()
+  # browser()
   has_legend = FALSE
   dual_legend = bubble && !null_by && !isFALSE(legend)
   lgnd_cex = NULL
diff --git a/R/type_points.R b/R/type_points.R
index 77f45cd4..78ef78bc 100644
--- a/R/type_points.R
+++ b/R/type_points.R
@@ -59,9 +59,10 @@ data_points = function(clim = c(0.5, 2.5)) {
       bubble = TRUE 
       ## Identify the pretty break points for our bubble labels
       bubble_labs = pretty(cex, n = 5)
+      len_labs = length(bubble_labs)
       cex = rescale_num(c(bubble_labs, cex), to = clim)
-      bubble_cex = cex[1:5]
-      cex = cex[(5+1):length(cex)]
+      bubble_cex = cex[1:len_labs]
+      cex = cex[(len_labs+1):length(cex)]
       names(bubble_cex) = format(bubble_labs)
     }
     

From 1666cb93d63055542cb6f72b24b9399652dec155 Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Sat, 28 Jun 2025 12:33:29 -0700
Subject: [PATCH 06/29] bubble support for type_text

---
 R/type_text.R | 26 +++++++++++++++++++++++---
 1 file changed, 23 insertions(+), 3 deletions(-)

diff --git a/R/type_text.R b/R/type_text.R
index db2701f6..0340b70c 100644
--- a/R/type_text.R
+++ b/R/type_text.R
@@ -42,14 +42,34 @@ type_text = function(labels, adj = NULL, pos = NULL, offset = 0.5, vfont = NULL,
   return(out)
 }
 
-data_text = function(labels) {
-  fun = function(datapoints, ...) {
+data_text = function(labels, clim = c(0.5, 2.5)) {
+  fun = function(datapoints, cex = NULL, ...) {
     if (length(labels) != 1 && length(labels) != nrow(datapoints)) {
       msg <- sprintf("`labels` must be of length 1 or %s.", nrow(datapoints))
       stop(msg, call. = FALSE)
     }
     datapoints$labels = labels
-    out = list(datapoints = datapoints)
+    
+    # browser()
+    bubble = FALSE
+    bubble_cex = 1
+    if (!is.null(cex) && length(cex) == nrow(datapoints)) {
+      bubble = TRUE 
+      ## Identify the pretty break points for our bubble labels
+      bubble_labs = pretty(cex, n = 5)
+      len_labs = length(bubble_labs)
+      cex = rescale_num(c(bubble_labs, cex), to = clim)
+      bubble_cex = cex[1:len_labs]
+      cex = cex[(len_labs+1):length(cex)]
+      names(bubble_cex) = format(bubble_labs)
+    }
+
+    out = list(
+      datapoints = datapoints,
+      cex = cex,
+      bubble = bubble,
+      bubble_cex = bubble_cex
+    )
     return(out)
   }
   return(fun)

From 47e148fdb3cfee97b35ee1acc41d1597b38277c6 Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Sat, 28 Jun 2025 20:59:51 -0700
Subject: [PATCH 07/29] better scaling adjustment?

---
 R/type_points.R | 3 ++-
 R/type_text.R   | 3 ++-
 2 files changed, 4 insertions(+), 2 deletions(-)

diff --git a/R/type_points.R b/R/type_points.R
index 78ef78bc..ba9b15a9 100644
--- a/R/type_points.R
+++ b/R/type_points.R
@@ -60,7 +60,8 @@ data_points = function(clim = c(0.5, 2.5)) {
       ## Identify the pretty break points for our bubble labels
       bubble_labs = pretty(cex, n = 5)
       len_labs = length(bubble_labs)
-      cex = rescale_num(c(bubble_labs, cex), to = clim)
+      # cex = rescale_num(c(bubble_labs, cex), to = clim)
+      cex = rescale_num(sqrt(c(bubble_labs, cex))/pi, to = clim)
       bubble_cex = cex[1:len_labs]
       cex = cex[(len_labs+1):length(cex)]
       names(bubble_cex) = format(bubble_labs)
diff --git a/R/type_text.R b/R/type_text.R
index 0340b70c..d69d118b 100644
--- a/R/type_text.R
+++ b/R/type_text.R
@@ -58,7 +58,8 @@ data_text = function(labels, clim = c(0.5, 2.5)) {
       ## Identify the pretty break points for our bubble labels
       bubble_labs = pretty(cex, n = 5)
       len_labs = length(bubble_labs)
-      cex = rescale_num(c(bubble_labs, cex), to = clim)
+      # cex = rescale_num(c(bubble_labs, cex), to = clim)
+      cex = rescale_num(sqrt(c(bubble_labs, cex))/pi, to = clim)
       bubble_cex = cex[1:len_labs]
       cex = cex[(len_labs+1):length(cex)]
       names(bubble_cex) = format(bubble_labs)

From 9316929a2e7bec729819d9384ec5043c959e5ef1 Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Sat, 28 Jun 2025 21:01:49 -0700
Subject: [PATCH 08/29] mwe for dual axes

---
 R/draw_legend.R |   6 +-
 R/tinyplot.R    | 154 ++++++++++++++++++++++++++++++++++++++----------
 2 files changed, 128 insertions(+), 32 deletions(-)

diff --git a/R/draw_legend.R b/R/draw_legend.R
index e7be3d42..7c9408fa 100644
--- a/R/draw_legend.R
+++ b/R/draw_legend.R
@@ -504,7 +504,11 @@ tinylegend = function(
   setHook("before.plot.new", oldhook, action = "replace")
   
   # Finally, set the inset as part of the legend args.
-  legend_args[["inset"]] = inset
+  if (is.null(legend_args[["inset"]])) {
+    legend_args[["inset"]] = inset
+  } else {
+    legend_args[["inset"]] = legend_args[["inset"]] + inset
+  }
   
   #
   ## Step 3: Draw the legend
diff --git a/R/tinyplot.R b/R/tinyplot.R
index 9148cf9d..8ea0d183 100644
--- a/R/tinyplot.R
+++ b/R/tinyplot.R
@@ -1031,7 +1031,7 @@ tinyplot.default = function(
 
   if ((is.null(legend) || legend != "none" || bubble) && !add) {
     # browser()
-    if (isFALSE(by_continuous) && !bubble) {
+    if (isFALSE(by_continuous) && (!bubble || dual_legend)) {
       if (ngrps > 1) {
         lgnd_labs = if (is.factor(datapoints$by)) levels(datapoints$by) else unique(datapoints$by)
       } else {
@@ -1046,10 +1046,12 @@ tinyplot.default = function(
       legend_args[["lty"]] = 0
     }
 
-    if (is.null(lgnd_cex)) lgnd_cex = cex * cex_fct_adj
+    # browser()
 
-    if (dual_legend) {
-      l = draw_legend(
+    if (!dual_legend) {
+      ## simple case: single legend only
+      if (is.null(lgnd_cex)) lgnd_cex = cex * cex_fct_adj
+      draw_legend(
         legend = legend,
         legend_args = legend_args,
         by_dep = by_dep,
@@ -1061,38 +1063,128 @@ tinyplot.default = function(
         col = col,
         bg = bg,
         gradient = by_continuous,
-        # cex = cex * cex_fct_adj,
         cex = lgnd_cex,
+        has_sub = has_sub
+      )
+    } else {
+      ## dual legend case...
+      ## FIXME: current logic only works for "right!" legend
+      legend = "bottomright!" # need bottom otherwise can't adjust inset vertically
+      
+      # legend 1: by grouping
+      l1 = draw_legend(
+        legend = legend,
+        legend_args = legend_args,
+        by_dep = by_dep,
+        lgnd_labs = lgnd_labs,
+        type = type,
+        pch = pch,
+        lty = lty,
+        lwd = lwd,
+        col = col,
+        bg = bg,
+        gradient = by_continuous,
+        cex = cex * cex_fct_adj,
+        # cex = lgnd_cex,
         has_sub = has_sub,
         draw = FALSE
       )
+      # legend 2: bubble
+      l2 = draw_legend(
+        legend = legend,
+        legend_args = modifyList(legend_args, list(title = cex_dep), keep.null = TRUE),
+        # by_dep = cex_dep,
+        lgnd_labs = names(bubble_cex),
+        type = type,
+        pch = pch,
+        lty = lty,
+        lwd = lwd,
+        col = col,
+        bg = bg,
+        gradient = by_continuous,
+        cex = bubble_cex * cex_fct_adj,
+        has_sub = has_sub,
+        draw = FALSE
+      )
+
+      # reposition and plot both legends
+      l1w = l1$rect$w
+      l2w = l2$rect$w
+      l1h = l1$rect$h
+      l2h = l2$rect$h
+      
+      # browser()
+      # order depends on which legend is "wider"
+      if (l1w > l2w) {
+        # normal legend is wider; draw bubble first
+        draw_legend(
+          legend = legend,
+          legend_args = modifyList(legend_args, list(title = cex_dep, inset = c((l2w-l1w)/2, .4-l2h/2)), keep.null = TRUE),
+          lgnd_labs = names(bubble_cex),
+          type = type,
+          pch = par("pch"),
+          lty = lty,
+          lwd = lwd,
+          col = par("col"),
+          bg = par("col"),
+          cex = bubble_cex * cex_fct_adj,
+          has_sub = has_sub
+        )
+        draw_legend(
+          legend = legend,#NULL,
+          legend_args = modifyList(legend_args, list(inset = c(0, .5+l1h/2)), keep.null = TRUE),
+          by_dep = by_dep,
+          lgnd_labs = lgnd_labs,
+          type = type,
+          pch = pch,
+          lty = lty,
+          lwd = lwd,
+          col = col,
+          bg = bg,
+          gradient = by_continuous,
+          cex = cex * cex_fct_adj,
+          # cex = lgnd_cex,
+          has_sub = has_sub,
+          new_plot = FALSE # NB!
+        )
+
+      } else {
+        draw_legend(
+          legend = legend, #NULL,
+          legend_args = modifyList(legend_args, list(inset = c((l1w-l2w)/2,.5+l1h/2)), keep.null = TRUE),
+          by_dep = by_dep,
+          lgnd_labs = lgnd_labs,
+          type = type,
+          pch = pch,
+          lty = lty,
+          lwd = lwd,
+          col = col,
+          bg = bg,
+          gradient = by_continuous,
+          cex = cex * cex_fct_adj,
+          # cex = lgnd_cex,
+          has_sub = has_sub
+        )
+        # draw l2
+        draw_legend(
+          legend = legend,
+          legend_args = modifyList(legend_args, list(title = cex_dep, inset = c(0, .4-l2h/2)), keep.null = TRUE),
+          lgnd_labs = names(bubble_cex),
+          type = type,
+          pch = par("pch"),
+          lty = lty,
+          lwd = lwd,
+          col = par("col"),
+          bg = par("col"),
+          # gradient = by_continuous,
+          cex = bubble_cex * cex_fct_adj,
+          has_sub = has_sub,
+          new_plot = FALSE # NB!
+        )
+        
+      }
+      
     }
-    # ## MANUAL BUBBLE TEST
-    # draw_legend(
-    #   legend = "right!",
-    #   legend_args = list(title = cex_dep),
-    #   lgnd_labs = names(bubble_cex),
-    #   type = type, pch = pch,
-    #   cex = bubble_cex * cex_fct_adj,
-    #   has_sub = has_sub
-    # )
-    # ## END MANUAL TEST
-    draw_legend(
-      legend = legend,
-      legend_args = legend_args,
-      by_dep = by_dep,
-      lgnd_labs = lgnd_labs,
-      type = type,
-      pch = pch,
-      lty = lty,
-      lwd = lwd,
-      col = col,
-      bg = bg,
-      gradient = by_continuous,
-      # cex = cex * cex_fct_adj,
-      cex = lgnd_cex,
-      has_sub = has_sub
-    )
 
     has_legend = TRUE
     } else if (legend_args[["x"]] == "none" && !add) {

From f07270c0828ca17ef5cd6916b1ce3192fdeee321 Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Sat, 28 Jun 2025 21:04:35 -0700
Subject: [PATCH 09/29] clean up

---
 R/draw_legend.R |  1 -
 R/tinyplot.R    | 30 +-----------------------------
 2 files changed, 1 insertion(+), 30 deletions(-)

diff --git a/R/draw_legend.R b/R/draw_legend.R
index 7c9408fa..d18b13f3 100644
--- a/R/draw_legend.R
+++ b/R/draw_legend.R
@@ -168,7 +168,6 @@ draw_legend = function(
       legend_args[["lwd"]] = legend_args[["lwd"]] %||% lwd
     }
   
-    # browser()
     # if (isTRUE(type %in% c("p", "pointrange", "errorbar")) && (length(col) == 1 || length(cex) == 1)) {
     if (type %in% c("p", "pointrange", "errorbar", "text")) {
       legend_args[["pt.cex"]] = legend_args[["pt.cex"]] %||% (cex %||% par("cex"))
diff --git a/R/tinyplot.R b/R/tinyplot.R
index 8ea0d183..63c5cc15 100644
--- a/R/tinyplot.R
+++ b/R/tinyplot.R
@@ -620,7 +620,6 @@ tinyplot.default = function(
     # assign(".last_call", cal, envir = get(".tinyplot_env", envir = parent.env(environment())))
   }
 
-  # browser()
   dots = list(...)
 
   if (add) legend = FALSE
@@ -783,7 +782,6 @@ tinyplot.default = function(
   # alias
   if (is.null(bg) && !is.null(fill)) bg = fill
 
-  # TEST bubble
   bubble = FALSE
 
   datapoints = list(x = x, y = y, xmin = xmin, xmax = xmax, ymin = ymin, ymax = ymax, ygroup = ygroup)
@@ -934,12 +932,8 @@ tinyplot.default = function(
   pch = by_pch(ngrps = ngrps, type = type, pch = pch)
   lty = by_lty(ngrps = ngrps, type = type, lty = lty)
   lwd = by_lwd(ngrps = ngrps, type = type, lwd = lwd)
-  ## TEST
-  # browser()
-  # if (bubble) bubble_labs = cex ## need to do this in type_points...
   cex = by_cex(ngrps = ngrps, type = type, bubble = bubble, cex = cex)
   if (bubble) split_data[[1]][["cex"]] = cex ## check (maybe ngrps!=length(cex)?)
-  ## END TEST
   col = by_col(
     ngrps = ngrps, col = col, palette = palette,
     gradient = by_continuous, ordered = by_ordered, alpha = alpha)
@@ -985,7 +979,6 @@ tinyplot.default = function(
   # place and draw the legend
   
    # simple indicator variables for later use
-  # browser()
   has_legend = FALSE
   dual_legend = bubble && !null_by && !isFALSE(legend)
   lgnd_cex = NULL
@@ -1008,8 +1001,6 @@ tinyplot.default = function(
 
   if (null_by) {
     if (is.null(legend)) {
-      ## TEST
-      # browser()
       # special case: bubble legend, no by legend
       if (bubble && !dual_legend) {
         legend_args[["title"]] = cex_dep ## rather by_dep?
@@ -1019,9 +1010,6 @@ tinyplot.default = function(
         legend = "none"
         legend_args[["x"]] = "none"
       }
-      ## END TEST
-      # legend = "none"
-      # legend_args[["x"]] = "none"
     } else if (bubble && !dual_legend) {
         legend_args[["title"]] = cex_dep ## rather by_dep?
         lgnd_labs = names(bubble_cex)
@@ -1030,7 +1018,6 @@ tinyplot.default = function(
   }
 
   if ((is.null(legend) || legend != "none" || bubble) && !add) {
-    # browser()
     if (isFALSE(by_continuous) && (!bubble || dual_legend)) {
       if (ngrps > 1) {
         lgnd_labs = if (is.factor(datapoints$by)) levels(datapoints$by) else unique(datapoints$by)
@@ -1046,8 +1033,6 @@ tinyplot.default = function(
       legend_args[["lty"]] = 0
     }
 
-    # browser()
-
     if (!dual_legend) {
       ## simple case: single legend only
       if (is.null(lgnd_cex)) lgnd_cex = cex * cex_fct_adj
@@ -1113,7 +1098,6 @@ tinyplot.default = function(
       l1h = l1$rect$h
       l2h = l2$rect$h
       
-      # browser()
       # order depends on which legend is "wider"
       if (l1w > l2w) {
         # normal legend is wider; draw bubble first
@@ -1398,7 +1382,6 @@ tinyplot.default = function(
 
   ## Outer loop over the facets
   for (i in seq_along(split_data)) {
-    # browser()
     # Split group-level data again to grab any "by" groups
     idata = split_data[[i]]
     iby = idata[["by"]]
@@ -1406,15 +1389,6 @@ tinyplot.default = function(
       if (isTRUE(by_continuous)) {
         idata[["col"]] = col[round(rescale_num(idata$by, from = range(datapoints$by), to = c(1, 100)))]
         idata[["bg"]] = bg[round(rescale_num(idata$by, from = range(datapoints$by), to = c(1, 100)))]
-        # ## TEST
-        # if (cex_continuous) {
-        #   if (cex_by) { ## NEED TO CONSTRUCT ABOVE
-        #     idata[["cex"]] = cex[round(rescale_num(idata$by, from = range(datapoints$by), to = c(1, 2.5)))]
-        #   } else { ## WHAT SHOULD THIS BE??
-        #     idata[["cex"]] = cex[round(rescale_num(idata$by, from = range(datapoints$by), to = c(1, 2.5)))]
-        #   }
-        # }
-        # ## END TEST
         idata = list(idata)
       } else {
         idata = lapply(idata, split, iby)
@@ -1461,6 +1435,7 @@ tinyplot.default = function(
       ipch = pch[ii]
       ilty = lty[ii]
       ilwd = lwd[ii]
+      icex = if (bubble) idata[[ii]][["cex"]] else cex[[ii]]
       
       ix = idata[[ii]][["x"]]
       iy = idata[[ii]][["y"]]
@@ -1476,9 +1451,6 @@ tinyplot.default = function(
         ibg = idata[[ii]]$bg
       }
 
-      # browser() ## bubble test
-      icex = if (bubble) idata[[ii]][["cex"]] else cex[[ii]]
-
       # empty plot flag
       empty_plot = FALSE
       if (isTRUE(empty) || isTRUE(type == "n") || ((length(ix) == 0) && !(type %in% c("histogram", "hist", "rect", "segments", "spineplot")))) {

From 0eb83b5cde0882526f785aa3b99c5337e669aba6 Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Sat, 28 Jun 2025 21:30:11 -0700
Subject: [PATCH 10/29] docs

---
 R/type_text.R      | 6 ++++--
 man/tinyplot.Rd    | 2 +-
 man/type_points.Rd | 6 +++++-
 man/type_text.Rd   | 6 +++++-
 4 files changed, 15 insertions(+), 5 deletions(-)

diff --git a/R/type_text.R b/R/type_text.R
index d69d118b..26f8eda2 100644
--- a/R/type_text.R
+++ b/R/type_text.R
@@ -9,6 +9,8 @@
 #' @param xpd Logical value or `NA` denoting text clipping behaviour, following
 #'  [graphics::par()].
 #' @param srt Numeric giving the desired string rotation in degrees.
+#' @param clim Numeric giving the lower and upper limits of the character
+#'   expansion (`cex`) normalization for bubble charts.
 #' @inheritParams graphics::text
 #' @examples
 #' tinyplot(mpg ~ hp | factor(cyl),
@@ -32,9 +34,9 @@
 #' )
 #'
 #' @export
-type_text = function(labels, adj = NULL, pos = NULL, offset = 0.5, vfont = NULL, font = NULL, xpd = NULL, srt = 0) {
+type_text = function(labels, adj = NULL, pos = NULL, offset = 0.5, vfont = NULL, font = NULL, xpd = NULL, srt = 0, clim = c(0.5, 2.5)) {
   out = list(
-    draw = draw_text(adj = adj, pos = pos, offset = offset, vfont = vfont, font = font, xpd = xpd, srt = srt),
+    draw = draw_text(adj = adj, pos = pos, offset = offset, vfont = vfont, font = font, xpd = xpd, srt = srt, clim = clim),
     data = data_text(labels = labels),
     name = "text"
   )
diff --git a/man/tinyplot.Rd b/man/tinyplot.Rd
index 65787c0e..f3fe2047 100644
--- a/man/tinyplot.Rd
+++ b/man/tinyplot.Rd
@@ -51,7 +51,7 @@ tinyplot(x, ...)
   bg = NULL,
   fill = NULL,
   alpha = NULL,
-  cex = 1,
+  cex = NULL,
   add = FALSE,
   draw = NULL,
   empty = FALSE,
diff --git a/man/type_points.Rd b/man/type_points.Rd
index 6c0d9b44..fed3f61e 100644
--- a/man/type_points.Rd
+++ b/man/type_points.Rd
@@ -4,7 +4,11 @@
 \alias{type_points}
 \title{Points plot type}
 \usage{
-type_points()
+type_points(clim = c(0.5, 2.5))
+}
+\arguments{
+\item{clim}{Numeric giving the lower and upper limits of the character
+expansion (\code{cex}) normalization for bubble charts.}
 }
 \description{
 Type function for plotting points, i.e. a scatter plot.
diff --git a/man/type_text.Rd b/man/type_text.Rd
index 2b9591ea..8b930e26 100644
--- a/man/type_text.Rd
+++ b/man/type_text.Rd
@@ -12,7 +12,8 @@ type_text(
   vfont = NULL,
   font = NULL,
   xpd = NULL,
-  srt = 0
+  srt = 0,
+  clim = c(0.5, 2.5)
 )
 }
 \arguments{
@@ -47,6 +48,9 @@ number of x,y coordinates.}
 \code{\link[graphics:par]{graphics::par()}}.}
 
 \item{srt}{Numeric giving the desired string rotation in degrees.}
+
+\item{clim}{Numeric giving the lower and upper limits of the character
+expansion (\code{cex}) normalization for bubble charts.}
 }
 \description{
 Type function for adding text annotations to a plot. This function allows

From 1ed245cf1cc3bbf5fdc8f838126d027b65943336 Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Sat, 28 Jun 2025 21:32:14 -0700
Subject: [PATCH 11/29] more docs

---
 R/type_text.R | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/R/type_text.R b/R/type_text.R
index 26f8eda2..398478d4 100644
--- a/R/type_text.R
+++ b/R/type_text.R
@@ -36,8 +36,8 @@
 #' @export
 type_text = function(labels, adj = NULL, pos = NULL, offset = 0.5, vfont = NULL, font = NULL, xpd = NULL, srt = 0, clim = c(0.5, 2.5)) {
   out = list(
-    draw = draw_text(adj = adj, pos = pos, offset = offset, vfont = vfont, font = font, xpd = xpd, srt = srt, clim = clim),
-    data = data_text(labels = labels),
+    draw = draw_text(adj = adj, pos = pos, offset = offset, vfont = vfont, font = font, xpd = xpd, srt = srt),
+    data = data_text(labels = labels, clim = clim),
     name = "text"
   )
   class(out) = "tinyplot_type"

From 926b1c2a45831240807ea46d36c4fbcdf280e70c Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Sat, 28 Jun 2025 21:33:12 -0700
Subject: [PATCH 12/29] global var

---
 R/zzz.R | 1 +
 1 file changed, 1 insertion(+)

diff --git a/R/zzz.R b/R/zzz.R
index 08f3f4ee..07e6256b 100644
--- a/R/zzz.R
+++ b/R/zzz.R
@@ -21,6 +21,7 @@
     "axes",
     "by_continuous",
     "by_ordered",
+    "bubble_cex",
     "cex_fct_adj",
     "dots",
     "draw",

From 2caf052edcb4b62e255c67df4a8d0eec7bfdd2e5 Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Sat, 28 Jun 2025 21:39:22 -0700
Subject: [PATCH 13/29] catch for new cex logic

---
 R/draw_legend.R | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/R/draw_legend.R b/R/draw_legend.R
index d18b13f3..6fe3add8 100644
--- a/R/draw_legend.R
+++ b/R/draw_legend.R
@@ -168,8 +168,7 @@ draw_legend = function(
       legend_args[["lwd"]] = legend_args[["lwd"]] %||% lwd
     }
   
-    # if (isTRUE(type %in% c("p", "pointrange", "errorbar")) && (length(col) == 1 || length(cex) == 1)) {
-    if (type %in% c("p", "pointrange", "errorbar", "text")) {
+    if (is.null(type) || type %in% c("p", "pointrange", "errorbar", "text")) {
       legend_args[["pt.cex"]] = legend_args[["pt.cex"]] %||% (cex %||% par("cex"))
     }
   

From ec01a122b9eafc3fb7cc9e76c4353692d9902ced Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Sat, 5 Jul 2025 19:15:18 -0700
Subject: [PATCH 14/29] minor

---
 R/draw_legend.R | 1 -
 1 file changed, 1 deletion(-)

diff --git a/R/draw_legend.R b/R/draw_legend.R
index 6fe3add8..8f1a9a46 100644
--- a/R/draw_legend.R
+++ b/R/draw_legend.R
@@ -351,7 +351,6 @@ draw_legend = function(
     
     #
     ## draw the legend ----
-    
     # Legend drawing is handled by the internal `tinylegend()` function, which:
     #   1. calculates appropriate insets for "outer" legend placement
     #   2. can draw gradient legends (via `gradient_legend()` below)

From 4b69bf6480afe942940915c9312b4916aa41d7f5 Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Sun, 20 Jul 2025 09:02:32 -0700
Subject: [PATCH 15/29] catch for (and exception for) multicolumn by legend
 with dual bubble legend

---
 R/draw_legend.R | 8 +++++---
 R/tinyplot.R    | 8 +++++---
 2 files changed, 10 insertions(+), 6 deletions(-)

diff --git a/R/draw_legend.R b/R/draw_legend.R
index 21476fe1..76b86289 100644
--- a/R/draw_legend.R
+++ b/R/draw_legend.R
@@ -218,7 +218,9 @@ draw_legend = function(
     if (isTRUE(gradient)) {
       legend_args[["ncol"]] = NULL
     }
-    
+    # flag for multicolumn legend
+    mcol_flag = !is.null(legend_args[["ncol"]]) && legend_args[["ncol"]] > 1
+  
     #
     ## legend placement ----
     
@@ -345,7 +347,7 @@ draw_legend = function(
     }
   
     # Additional tweaks for horiz and/or multi-column legends
-    if (isTRUE(legend_args[["horiz"]]) ||  !is.null(legend_args[["ncol"]])) {
+    if (isTRUE(legend_args[["horiz"]]) ||  mcol_flag) {
       # tighter horizontal labelling
       # See: https://github.com/grantmcdermott/tinyplot/issues/434
       if (!gradient) {
@@ -353,7 +355,7 @@ draw_legend = function(
         # Add a space to all labs except the outer most right ones
         nlabs = length(legend_args[["legend"]])
         nidx = nlabs
-        if (!is.null(legend_args[["ncol"]])) nidx = tail(1:nlabs, (nlabs %/% legend_args[["ncol"]]))
+        if (mcol_flag) nidx = tail(1:nlabs, (nlabs %/% legend_args[["ncol"]]))
         legend_args[["legend"]][-nidx] = paste(legend_args[["legend"]][-nidx], " ")
       }
       # catch for horizontal ribbon legend spacing
diff --git a/R/tinyplot.R b/R/tinyplot.R
index 71a2f8a6..17202ef8 100644
--- a/R/tinyplot.R
+++ b/R/tinyplot.R
@@ -1053,6 +1053,7 @@ tinyplot.default = function(
     } else {
       ## dual legend case...
       ## FIXME: current logic only works for "right!" legend
+      if (is.call(legend)) legend_args = modifyList(legend_args, as.list(legend), keep.null = TRUE)
       legend = "bottomright!" # need bottom otherwise can't adjust inset vertically
       
       # legend 1: by grouping
@@ -1076,7 +1077,7 @@ tinyplot.default = function(
       # legend 2: bubble
       l2 = draw_legend(
         legend = legend,
-        legend_args = modifyList(legend_args, list(title = cex_dep), keep.null = TRUE),
+        legend_args = modifyList(legend_args, list(title = cex_dep, ncol = 1), keep.null = TRUE),
         # by_dep = cex_dep,
         lgnd_labs = names(bubble_cex),
         type = type,
@@ -1102,7 +1103,7 @@ tinyplot.default = function(
         # normal legend is wider; draw bubble first
         draw_legend(
           legend = legend,
-          legend_args = modifyList(legend_args, list(title = cex_dep, inset = c((l2w-l1w)/2, .4-l2h/2)), keep.null = TRUE),
+          legend_args = modifyList(legend_args, list(title = cex_dep, ncol = 1, inset = c((l2w-l1w)/2, .4-l2h/2)), keep.null = TRUE),
           lgnd_labs = names(bubble_cex),
           type = type,
           pch = par("pch"),
@@ -1132,6 +1133,7 @@ tinyplot.default = function(
         )
 
       } else {
+        # bubble legend is wider; draw normal legend first
         draw_legend(
           legend = legend, #NULL,
           legend_args = modifyList(legend_args, list(inset = c((l1w-l2w)/2,.5+l1h/2)), keep.null = TRUE),
@@ -1151,7 +1153,7 @@ tinyplot.default = function(
         # draw l2
         draw_legend(
           legend = legend,
-          legend_args = modifyList(legend_args, list(title = cex_dep, inset = c(0, .4-l2h/2)), keep.null = TRUE),
+          legend_args = modifyList(legend_args, list(title = cex_dep, ncol = 1, inset = c(0, .4-l2h/2)), keep.null = TRUE),
           lgnd_labs = names(bubble_cex),
           type = type,
           pch = par("pch"),

From eb3c55eff76923a4aa613a3377e2e12bd36f85fb Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Sun, 20 Jul 2025 10:58:03 -0700
Subject: [PATCH 16/29] clearer commenting

---
 R/tinyplot.R | 68 +++++++++++++++++++++++++++++++++++++++-------------
 1 file changed, 51 insertions(+), 17 deletions(-)

diff --git a/R/tinyplot.R b/R/tinyplot.R
index 17202ef8..8639a6b3 100644
--- a/R/tinyplot.R
+++ b/R/tinyplot.R
@@ -1057,7 +1057,7 @@ tinyplot.default = function(
       legend = "bottomright!" # need bottom otherwise can't adjust inset vertically
       
       # legend 1: by grouping
-      l1 = draw_legend(
+      lgby = draw_legend(
         legend = legend,
         legend_args = legend_args,
         by_dep = by_dep,
@@ -1075,9 +1075,13 @@ tinyplot.default = function(
         draw = FALSE
       )
       # legend 2: bubble
-      l2 = draw_legend(
+      lgbub = draw_legend(
         legend = legend,
-        legend_args = modifyList(legend_args, list(title = cex_dep, ncol = 1), keep.null = TRUE),
+        legend_args = modifyList(
+          legend_args,
+          list(title = cex_dep, ncol = 1),
+          keep.null = TRUE
+        ),
         # by_dep = cex_dep,
         lgnd_labs = names(bubble_cex),
         type = type,
@@ -1092,18 +1096,30 @@ tinyplot.default = function(
         draw = FALSE
       )
 
-      # reposition and plot both legends
-      l1w = l1$rect$w
-      l2w = l2$rect$w
-      l1h = l1$rect$h
-      l2h = l2$rect$h
+      # grab respective legend dimensions
+      lgby_w = lgby$rect$w
+      lgby_h = lgby$rect$h
+      lgbub_w = lgbub$rect$w
+      lgbub_h = lgbub$rect$h
       
-      # order depends on which legend is "wider"
-      if (l1w > l2w) {
-        # normal legend is wider; draw bubble first
+      # Finally, reposition (via adjusted an `inset` arg) and draw both legends
+      # Note: the drawing order depends on which legend is "wider", since this
+      #   helps to correctly set the overall plot dimensions.
+
+      if (lgby_w > lgbub_w) {
+        # case I: by legend is wider; draw bubble first
+        ## first, draw bubble legend
         draw_legend(
           legend = legend,
-          legend_args = modifyList(legend_args, list(title = cex_dep, ncol = 1, inset = c((l2w-l1w)/2, .4-l2h/2)), keep.null = TRUE),
+          legend_args = modifyList(
+            legend_args,
+            list(
+              title = cex_dep,
+              ncol = 1,
+              inset = c((lgbub_w-lgby_w)/2, .4-lgbub_h/2)
+            ),
+            keep.null = TRUE
+          ),
           lgnd_labs = names(bubble_cex),
           type = type,
           pch = par("pch"),
@@ -1114,9 +1130,14 @@ tinyplot.default = function(
           cex = bubble_cex * cex_fct_adj,
           has_sub = has_sub
         )
+        ## next, draw by legend (with new_plot = FALSE)
         draw_legend(
           legend = legend,#NULL,
-          legend_args = modifyList(legend_args, list(inset = c(0, .5+l1h/2)), keep.null = TRUE),
+          legend_args = modifyList(
+            legend_args,
+            list(inset = c(0, .5+lgby_h/2)),
+            keep.null = TRUE
+          ),
           by_dep = by_dep,
           lgnd_labs = lgnd_labs,
           type = type,
@@ -1133,10 +1154,15 @@ tinyplot.default = function(
         )
 
       } else {
-        # bubble legend is wider; draw normal legend first
+        # Case II: bubble legend is wider; draw by legend first
+        ## first, draw by legend
         draw_legend(
           legend = legend, #NULL,
-          legend_args = modifyList(legend_args, list(inset = c((l1w-l2w)/2,.5+l1h/2)), keep.null = TRUE),
+          legend_args = modifyList(
+            legend_args,
+            list(inset = c((lgby_w-lgbub_w)/2,.5+lgby_h/2)),
+            keep.null = TRUE
+          ),
           by_dep = by_dep,
           lgnd_labs = lgnd_labs,
           type = type,
@@ -1150,10 +1176,18 @@ tinyplot.default = function(
           # cex = lgnd_cex,
           has_sub = has_sub
         )
-        # draw l2
+        ## next, draw bubble legend (with plot_new = FALSE)
         draw_legend(
           legend = legend,
-          legend_args = modifyList(legend_args, list(title = cex_dep, ncol = 1, inset = c(0, .4-l2h/2)), keep.null = TRUE),
+          legend_args = modifyList(
+            legend_args,
+            list(
+              title = cex_dep,
+              ncol = 1,
+              inset = c(0, .4-lgbub_h/2)
+            ),
+            keep.null = TRUE
+          ),
           lgnd_labs = names(bubble_cex),
           type = type,
           pch = par("pch"),

From 3996d53f9cdf6f6d9d0eb6e805ead48b2ec640bd Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Sun, 20 Jul 2025 18:01:03 -0700
Subject: [PATCH 17/29] better vertical adjustment / dual legend spacing

---
 R/tinyplot.R | 20 +++++++++++++-------
 1 file changed, 13 insertions(+), 7 deletions(-)

diff --git a/R/tinyplot.R b/R/tinyplot.R
index 8639a6b3..95945184 100644
--- a/R/tinyplot.R
+++ b/R/tinyplot.R
@@ -1101,7 +1101,10 @@ tinyplot.default = function(
       lgby_h = lgby$rect$h
       lgbub_w = lgbub$rect$w
       lgbub_h = lgbub$rect$h
-      
+      # for inset adjustment, default to 0.5 unless one or more of the two
+      # legends is bigger than half the plot height.
+      lgby_inset = if (lgby_h > 0.5 || lgbub_h > 0.5) lgbub_h / (lgby_h + lgbub_h) else 0.5
+
       # Finally, reposition (via adjusted an `inset` arg) and draw both legends
       # Note: the drawing order depends on which legend is "wider", since this
       #   helps to correctly set the overall plot dimensions.
@@ -1110,13 +1113,14 @@ tinyplot.default = function(
         # case I: by legend is wider; draw bubble first
         ## first, draw bubble legend
         draw_legend(
-          legend = legend,
+          # legend = legend,
+          legend = "topright!",
           legend_args = modifyList(
             legend_args,
             list(
               title = cex_dep,
               ncol = 1,
-              inset = c((lgbub_w-lgby_w)/2, .4-lgbub_h/2)
+              inset = c((lgbub_w-lgby_w)/2, 1 - lgby_inset + 0.01)
             ),
             keep.null = TRUE
           ),
@@ -1135,7 +1139,7 @@ tinyplot.default = function(
           legend = legend,#NULL,
           legend_args = modifyList(
             legend_args,
-            list(inset = c(0, .5+lgby_h/2)),
+            list(inset = c(0, lgby_inset + 0.01)),
             keep.null = TRUE
           ),
           by_dep = by_dep,
@@ -1154,13 +1158,14 @@ tinyplot.default = function(
         )
 
       } else {
+        # browser()
         # Case II: bubble legend is wider; draw by legend first
         ## first, draw by legend
         draw_legend(
           legend = legend, #NULL,
           legend_args = modifyList(
             legend_args,
-            list(inset = c((lgby_w-lgbub_w)/2,.5+lgby_h/2)),
+            list(inset = c((lgby_w-lgbub_w)/2, lgby_inset + 0.01)),
             keep.null = TRUE
           ),
           by_dep = by_dep,
@@ -1178,13 +1183,14 @@ tinyplot.default = function(
         )
         ## next, draw bubble legend (with plot_new = FALSE)
         draw_legend(
-          legend = legend,
+          # legend = legend,
+          legend = "topright!",
           legend_args = modifyList(
             legend_args,
             list(
               title = cex_dep,
               ncol = 1,
-              inset = c(0, .4-lgbub_h/2)
+              inset = c(0, 1 - lgby_inset + 0.01)
             ),
             keep.null = TRUE
           ),

From ca9f91aedc53ce1ef2663073ab875c1c3b74eb1d Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Sun, 20 Jul 2025 20:54:18 -0700
Subject: [PATCH 18/29] support "left!" for dual too

---
 R/draw_legend.R | 50 ++++++++++++++++++++++++++-----------------------
 R/tinyplot.R    | 41 ++++++++++++++++++++++++++++------------
 2 files changed, 56 insertions(+), 35 deletions(-)

diff --git a/R/draw_legend.R b/R/draw_legend.R
index 76b86289..4caef19e 100644
--- a/R/draw_legend.R
+++ b/R/draw_legend.R
@@ -130,29 +130,7 @@ draw_legend = function(
     #
     ## legend args ----
     
-    if (is.null(legend_args[["x"]])) {
-      if (is.null(legend)) {
-        legend_args[["x"]] = "right!"
-      } else if (is.character(legend)) {
-        legend_args = utils::modifyList(legend_args, list(x = legend))
-      } else if (class(legend) %in% c("call", "name")) {
-        largs = as.list(legend)
-        if (is.null(largs[["x"]])) {
-          lnms = names(largs)
-          # check second position b/c first will be a symbol 
-          if (is.null(lnms)) {
-            largs = stats::setNames(largs, c("", "x"))
-          } else if (length(largs)>=2 && lnms[2] == "") {
-            lnms[2] = "x"
-            largs = stats::setNames(largs, lnms)
-          } else {
-            largs[["x"]] = "right!"
-          }
-        }
-        # Finally, combine with any pre-existing legend args (e.g., title from the by label)
-        legend_args = utils::modifyList(legend_args, largs, keep.null = TRUE)
-      }
-    }
+    legend_args = sanitize_legend(legend, legend_args)
     
     ## Use `!exists` rather than `is.null` for title in case user specified no title
     if (!exists("title", where = legend_args)) legend_args[["title"]] = by_dep
@@ -728,3 +706,29 @@ gradient_legend = function(legend_args, fklgnd, lmar, outer_side, outer_end, out
 }
 
 
+sanitize_legend = function(legend, legend_args) {
+  if (is.null(legend_args[["x"]])) {
+    if (is.null(legend)) {
+      legend_args[["x"]] = "right!"
+    } else if (is.character(legend)) {
+      legend_args = utils::modifyList(legend_args, list(x = legend))
+    } else if (class(legend) %in% c("call", "name")) {
+      largs = as.list(legend)
+      if (is.null(largs[["x"]])) {
+        lnms = names(largs)
+        # check second position b/c first will be a symbol 
+        if (is.null(lnms)) {
+          largs = stats::setNames(largs, c("", "x"))
+        } else if (length(largs)>=2 && lnms[2] == "") {
+          lnms[2] = "x"
+          largs = stats::setNames(largs, lnms)
+        } else {
+          largs[["x"]] = "right!"
+        }
+      }
+      # Finally, combine with any pre-existing legend args (e.g., title from the by label)
+      legend_args = utils::modifyList(legend_args, largs, keep.null = TRUE)
+    }
+  }
+  return(legend_args)
+}
\ No newline at end of file
diff --git a/R/tinyplot.R b/R/tinyplot.R
index 95945184..642b099e 100644
--- a/R/tinyplot.R
+++ b/R/tinyplot.R
@@ -1052,13 +1052,32 @@ tinyplot.default = function(
       )
     } else {
       ## dual legend case...
-      ## FIXME: current logic only works for "right!" legend
-      if (is.call(legend)) legend_args = modifyList(legend_args, as.list(legend), keep.null = TRUE)
-      legend = "bottomright!" # need bottom otherwise can't adjust inset vertically
+
+      ## FIXME: current logic only works for "right!"/"left!" legend
+      legend_args = sanitize_legend(legend, legend_args)
+      legend_pos = legend_args[["x"]]
+      legend_args[["x"]] = NULL
+      if (!grepl("right!$|left!$", legend_pos)) {
+        warning(
+          '\nDual legends currently only work with "right!" or "left!" keyword positioning.\n',
+          'Reverting to "right!" default\n'
+        )
+        legend_pos = "right!"
+        lgby_pos = "bottomright!"
+        lgbub_pos = "topright!"
+      } else if (grepl("right!$", legend_pos)) {
+        lgby_pos = "bottomright!"
+        lgbub_pos = "topright!"
+      } else if (grepl("left!$", legend_pos)) {
+        lgby_pos = "bottomleft!"
+        lgbub_pos = "topleft!"
+      }
       
+      # first we get the respective legend rect dimensions (i.e., draw = FALSE)
+
       # legend 1: by grouping
       lgby = draw_legend(
-        legend = legend,
+        legend = lgby_pos,
         legend_args = legend_args,
         by_dep = by_dep,
         lgnd_labs = lgnd_labs,
@@ -1076,7 +1095,7 @@ tinyplot.default = function(
       )
       # legend 2: bubble
       lgbub = draw_legend(
-        legend = legend,
+        legend = lgbub_pos,
         legend_args = modifyList(
           legend_args,
           list(title = cex_dep, ncol = 1),
@@ -1113,8 +1132,7 @@ tinyplot.default = function(
         # case I: by legend is wider; draw bubble first
         ## first, draw bubble legend
         draw_legend(
-          # legend = legend,
-          legend = "topright!",
+          legend = lgbub_pos,
           legend_args = modifyList(
             legend_args,
             list(
@@ -1136,7 +1154,7 @@ tinyplot.default = function(
         )
         ## next, draw by legend (with new_plot = FALSE)
         draw_legend(
-          legend = legend,#NULL,
+          legend = lgby_pos,
           legend_args = modifyList(
             legend_args,
             list(inset = c(0, lgby_inset + 0.01)),
@@ -1162,7 +1180,7 @@ tinyplot.default = function(
         # Case II: bubble legend is wider; draw by legend first
         ## first, draw by legend
         draw_legend(
-          legend = legend, #NULL,
+          legend = lgby_pos,
           legend_args = modifyList(
             legend_args,
             list(inset = c((lgby_w-lgbub_w)/2, lgby_inset + 0.01)),
@@ -1183,8 +1201,7 @@ tinyplot.default = function(
         )
         ## next, draw bubble legend (with plot_new = FALSE)
         draw_legend(
-          # legend = legend,
-          legend = "topright!",
+          legend = lgbub_pos,
           legend_args = modifyList(
             legend_args,
             list(
@@ -1207,7 +1224,7 @@ tinyplot.default = function(
           new_plot = FALSE # NB!
         )
         
-      }
+      } ## end dual legend case
       
     }
 

From 4a17a1a9cca74e7e7841cccaafa6257ede762ca8 Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Sun, 20 Jul 2025 21:12:59 -0700
Subject: [PATCH 19/29] correct cex for by legend with dual legends

---
 R/tinyplot.R    | 8 ++++----
 R/type_points.R | 2 --
 2 files changed, 4 insertions(+), 6 deletions(-)

diff --git a/R/tinyplot.R b/R/tinyplot.R
index 642b099e..070b5c40 100644
--- a/R/tinyplot.R
+++ b/R/tinyplot.R
@@ -1169,8 +1169,8 @@ tinyplot.default = function(
           col = col,
           bg = bg,
           gradient = by_continuous,
-          cex = cex * cex_fct_adj,
-          # cex = lgnd_cex,
+          # cex = cex * cex_fct_adj,
+          cex = lgnd_cex,
           has_sub = has_sub,
           new_plot = FALSE # NB!
         )
@@ -1195,8 +1195,8 @@ tinyplot.default = function(
           col = col,
           bg = bg,
           gradient = by_continuous,
-          cex = cex * cex_fct_adj,
-          # cex = lgnd_cex,
+          # cex = cex * cex_fct_adj,
+          cex = lgnd_cex,
           has_sub = has_sub
         )
         ## next, draw bubble legend (with plot_new = FALSE)
diff --git a/R/type_points.R b/R/type_points.R
index ba9b15a9..9fbcd22d 100644
--- a/R/type_points.R
+++ b/R/type_points.R
@@ -52,7 +52,6 @@ data_points = function(clim = c(0.5, 2.5)) {
       ylabs = NULL
     }
 
-    # browser()
     bubble = FALSE
     bubble_cex = 1
     if (!is.null(cex) && length(cex) == nrow(datapoints)) {
@@ -60,7 +59,6 @@ data_points = function(clim = c(0.5, 2.5)) {
       ## Identify the pretty break points for our bubble labels
       bubble_labs = pretty(cex, n = 5)
       len_labs = length(bubble_labs)
-      # cex = rescale_num(c(bubble_labs, cex), to = clim)
       cex = rescale_num(sqrt(c(bubble_labs, cex))/pi, to = clim)
       bubble_cex = cex[1:len_labs]
       cex = cex[(len_labs+1):length(cex)]

From b2e83802095dd6b0d184ea375de8baf8e3df9bd4 Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Mon, 21 Jul 2025 12:21:43 -0700
Subject: [PATCH 20/29] fix dual gradient placement (via extra user_inset flag)

---
 R/draw_legend.R | 21 ++++++++++++++-------
 1 file changed, 14 insertions(+), 7 deletions(-)

diff --git a/R/draw_legend.R b/R/draw_legend.R
index 4caef19e..3bdda8ea 100644
--- a/R/draw_legend.R
+++ b/R/draw_legend.R
@@ -198,6 +198,9 @@ draw_legend = function(
     }
     # flag for multicolumn legend
     mcol_flag = !is.null(legend_args[["ncol"]]) && legend_args[["ncol"]] > 1
+    
+    # flag for (extra) user inset (also used for dual legends)
+    user_inset = !is.null(legend_args[["inset"]])
   
     #
     ## legend placement ----
@@ -368,6 +371,7 @@ draw_legend = function(
           outer_end = outer_end,
           outer_bottom = outer_bottom,
           gradient = gradient,
+          user_inset = user_inset,
           draw = draw
         ),
         list = list(
@@ -381,6 +385,7 @@ draw_legend = function(
           outer_end = outer_end,
           outer_bottom = outer_bottom,
           gradient = gradient,
+          user_inset = user_inset,
           draw = draw
         ),
         env = getNamespace("tinyplot")
@@ -401,6 +406,7 @@ tinylegend = function(
     ooma, omar, lmar, topmar_epsilon,
     outer_side, outer_right, outer_end, outer_bottom,
     gradient,
+    user_inset = FALSE,
     draw
 ) {
   
@@ -500,11 +506,7 @@ tinylegend = function(
   setHook("before.plot.new", oldhook, action = "replace")
   
   # Finally, set the inset as part of the legend args.
-  if (is.null(legend_args[["inset"]])) {
-    legend_args[["inset"]] = inset
-  } else {
-    legend_args[["inset"]] = legend_args[["inset"]] + inset
-  }
+  legend_args[["inset"]] = if (user_inset) legend_args[["inset"]] + inset else inset
   
   #
   ## Step 3: Draw the legend
@@ -522,7 +524,8 @@ tinylegend = function(
       outer_side = outer_side,
       outer_end = outer_end,
       outer_right = outer_right,
-      outer_bottom = outer_bottom
+      outer_bottom = outer_bottom,
+      user_inset = user_inset
     )
   } else {
     do.call("legend", legend_args)
@@ -536,7 +539,7 @@ tinylegend = function(
 # For gradient (i.e., continuous color) legends, we'll role our own bespoke
 # legend function based on grDevices::as.raster
 
-gradient_legend = function(legend_args, fklgnd, lmar, outer_side, outer_end, outer_right, outer_bottom) {
+gradient_legend = function(legend_args, fklgnd, lmar, outer_side, outer_end, outer_right, outer_bottom, user_inset = FALSE) {
   pal = legend_args[["col"]]
   lgnd_labs = legend_args[["legend"]]
   if (!is.null(legend_args[["horiz"]])) horiz = legend_args[["horiz"]] else horiz = FALSE
@@ -584,10 +587,12 @@ gradient_legend = function(legend_args, fklgnd, lmar, outer_side, outer_end, out
        rb2_adj = corners[4] - (grconvertY(5+1 + 2.5, from="lines", to="user") - grconvertY(0, from="lines", to="user"))
      }
    }
+   if (user_inset) rb2_adj = rb2_adj + legend_args[["inset"]][2] + 0.05
    rb4_adj = grconvertY(5+1, from="lines", to="user") - grconvertY(0, from="lines", to="user")
    
    if (outer_right) {
      rasterbox[1] = corners[2] + rb1_adj
+     if (user_inset) rasterbox[1] = rasterbox[1] - (corners[2] - legend_args[["inset"]][1])/2
      rasterbox[2] = rb2_adj 
      rasterbox[3] = rasterbox[1] + rb3_adj
      rasterbox[4] = rasterbox[2] + rb4_adj
@@ -706,6 +711,8 @@ gradient_legend = function(legend_args, fklgnd, lmar, outer_side, outer_end, out
 }
 
 
+# sanitize legend (helper function) ----
+
 sanitize_legend = function(legend, legend_args) {
   if (is.null(legend_args[["x"]])) {
     if (is.null(legend)) {

From 3090d4b4e12d8be32c47901226562c8c87f2cc7b Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Mon, 21 Jul 2025 20:08:09 -0700
Subject: [PATCH 21/29] docs

---
 R/tinyplot.R    | 51 ++++++++++++++++++++++++++++++++++++++---------
 man/tinyplot.Rd | 53 ++++++++++++++++++++++++++++++++++++++++---------
 2 files changed, 86 insertions(+), 18 deletions(-)

diff --git a/R/tinyplot.R b/R/tinyplot.R
index 070b5c40..f87d1df4 100644
--- a/R/tinyplot.R
+++ b/R/tinyplot.R
@@ -22,11 +22,11 @@
 #' @param by grouping variable(s). The default behaviour is for groups to be
 #'   represented in the form of distinct colours, which will also trigger an
 #'   automatic legend. (See `legend` below for customization options.) However,
-#'   groups can also be presented through other plot parameters (e.g., `pch` or
-#'   `lty`) by passing an appropriate "by" keyword; see Examples. Note that
-#'   continuous (i.e., gradient) colour legends are also supported if the user
-#'   passes a numeric or integer to `by`. To group by multiple variables, wrap
-#'   them with \code{\link[base]{interaction}}.
+#'   groups can also be presented through other plot parameters (e.g., `pch`,
+#'   `lty`, or `cex`) by passing an appropriate `"by"` keyword; see Examples.
+#'   Note that continuous (i.e., gradient) colour legends are also supported if
+#'   the user passes a numeric or integer to `by`. To group by multiple
+#'   variables, wrap them with \code{\link[base]{interaction}}.
 #' @param facet the faceting variable(s) that you want arrange separate plot
 #'   windows by. Can be specified in various ways:
 #'   - In "atomic" form, e.g. `facet = fvar`. To facet by multiple variables in
@@ -253,7 +253,7 @@
 #'   can supply a special `lwd = "by"` convenience argument, in which case the
 #'   line width will automatically loop over the number of groups. This
 #'   automatic looping will be centered at the global line width value (i.e.,
-# `   par("lwd")`) and pad on either side of that.
+#'   `par("lwd")`) and pad on either side of that.
 #' @param bg background fill color for the open plot symbols 21:25 (see
 #'   `points.default`), as well as ribbon and area plot types.
 #'   Users can also supply either one of two special convenience arguments that
@@ -278,8 +278,18 @@
 #'   fractional values, e.g. `0.5` for semi-transparency.
 #' @param cex character expansion. A numerical vector (can be a single value)
 #'   giving the amount by which plotting characters and symbols should be scaled
-#'   relative to the default. Note that NULL is equivalent to 1.0, while NA
-#'   renders the characters invisible.
+#'   relative to the default. Note that `NULL` is equivalent to 1.0, while `NA`
+#'   renders the characters invisible. There are two additional considerations,
+#'   specifically for points-alike plot types (e.g. `"p"`):
+#'   
+#'   - users can also supply a special `cex = "by"` convenience argument, in
+#'     which case the character expansion will automatically adjust by group
+#'     too. The range of this character expansion is controlled by the `clim`
+#'     argument in the respective types; see [`type_points()`] for example.
+#'   - passing a `cex` vector of equal length to the main `x` and `y` variables
+#'     (e.g., another column in the same dataset) will yield a "bubble"plot with
+#'     its own dedicated legend. This can provide a useful way to visualize an
+#'     extra dimension of the data; see Examples.
 #' @param subset,na.action,drop.unused.levels arguments passed to `model.frame`
 #'   when extracting the data from `formula` and `data`.
 #' @param add logical. If TRUE, then elements are added to the current plot rather
@@ -399,6 +409,17 @@
 #'   pch = 16,
 #'   cex = 2
 #' )
+#' 
+#' # Use the special "by" convenience keyword if you would like to map these
+#' # aesthetic features over groups too (i.e., in addition to the default
+#' # colour grouping)
+#' 
+#' tinyplot(
+#'   Temp ~ Day | Month,
+#'   data = aq,
+#'   pch = "by",
+#'   cex = "by"
+#' )
 #'
 #' # We can add alpha transparency for overlapping points
 #'
@@ -412,7 +433,7 @@
 #'
 #' # To get filled points with a common solid background color, use an
 #' # appropriate plotting character (21:25) and combine with one of the special
-#' # `bg` convenience arguments.
+#' # `bg`/`fill` convenience arguments.
 #' tinyplot(
 #'   Temp ~ Day | Month,
 #'   data = aq,
@@ -421,6 +442,18 @@
 #'   bg = 0.3, # numeric in [0,1] adds a grouped background fill with transparency
 #'   col = "black" # override default color mapping; give all points a black border
 #' )
+#' 
+#' # Aside: For "bubble" plots, pass an appropriate vector to the `cex` arg.
+#' # This can be useful for depicting an additional dimension of the data (here:
+#' # Wind).
+#' tinyplot(
+#'   Temp ~ Day | Month,
+#'   data = aq,
+#'   pch = 21,
+#'   cex = aq$Wind, # map character size to another feature in the data
+#'   bg = 0.3,
+#'   col = "black"
+#' )
 #'
 #' # Converting to a grouped line plot is a simple matter of adjusting the
 #' # `type` argument.
diff --git a/man/tinyplot.Rd b/man/tinyplot.Rd
index f3fe2047..629e4fb8 100644
--- a/man/tinyplot.Rd
+++ b/man/tinyplot.Rd
@@ -124,11 +124,11 @@ can be specified as \code{ymin = var} if \code{var} is a variable in \code{data}
 \item{by}{grouping variable(s). The default behaviour is for groups to be
 represented in the form of distinct colours, which will also trigger an
 automatic legend. (See \code{legend} below for customization options.) However,
-groups can also be presented through other plot parameters (e.g., \code{pch} or
-\code{lty}) by passing an appropriate "by" keyword; see Examples. Note that
-continuous (i.e., gradient) colour legends are also supported if the user
-passes a numeric or integer to \code{by}. To group by multiple variables, wrap
-them with \code{\link[base]{interaction}}.}
+groups can also be presented through other plot parameters (e.g., \code{pch},
+\code{lty}, or \code{cex}) by passing an appropriate \code{"by"} keyword; see Examples.
+Note that continuous (i.e., gradient) colour legends are also supported if
+the user passes a numeric or integer to \code{by}. To group by multiple
+variables, wrap them with \code{\link[base]{interaction}}.}
 
 \item{facet}{the faceting variable(s) that you want arrange separate plot
 windows by. Can be specified in various ways:
@@ -385,7 +385,8 @@ recycle as necessary.}
 number of categories in the \code{by} variable. See \code{lwd}. In addition, users
 can supply a special \code{lwd = "by"} convenience argument, in which case the
 line width will automatically loop over the number of groups. This
-automatic looping will be centered at the global line width value (i.e.,}
+automatic looping will be centered at the global line width value (i.e.,
+\code{par("lwd")}) and pad on either side of that.}
 
 \item{col}{plotting color. Character, integer, or vector of length equal to
 the number of categories in the \code{by} variable. See \code{col}. Note that the
@@ -424,8 +425,19 @@ fractional values, e.g. \code{0.5} for semi-transparency.}
 
 \item{cex}{character expansion. A numerical vector (can be a single value)
 giving the amount by which plotting characters and symbols should be scaled
-relative to the default. Note that NULL is equivalent to 1.0, while NA
-renders the characters invisible.}
+relative to the default. Note that \code{NULL} is equivalent to 1.0, while \code{NA}
+renders the characters invisible. There are two additional considerations,
+specifically for points-alike plot types (e.g. \code{"p"}):
+\itemize{
+\item users can also supply a special \code{cex = "by"} convenience argument, in
+which case the character expansion will automatically adjust by group
+too. The range of this character expansion is controlled by the \code{clim}
+argument in the respective types; see \code{\link[=type_points]{type_points()}} for example.
+\item passing a \code{cex} vector of equal length to the main \code{x} and \code{y} variables
+(e.g., another column in the same dataset) will yield a "bubble"plot with
+its own dedicated legend. This can provide a useful way to visualize an
+extra dimension of the data; see Examples.
+}}
 
 \item{add}{logical. If TRUE, then elements are added to the current plot rather
 than drawing a new plot window. Note that the automatic legend for the
@@ -562,6 +574,17 @@ tinyplot(
   cex = 2
 )
 
+# Use the special "by" convenience keyword if you would like to map these
+# aesthetic features over groups too (i.e., in addition to the default
+# colour grouping)
+
+tinyplot(
+  Temp ~ Day | Month,
+  data = aq,
+  pch = "by",
+  cex = "by"
+)
+
 # We can add alpha transparency for overlapping points
 
 tinyplot(
@@ -574,7 +597,7 @@ tinyplot(
 
 # To get filled points with a common solid background color, use an
 # appropriate plotting character (21:25) and combine with one of the special
-# `bg` convenience arguments.
+# `bg`/`fill` convenience arguments.
 tinyplot(
   Temp ~ Day | Month,
   data = aq,
@@ -584,6 +607,18 @@ tinyplot(
   col = "black" # override default color mapping; give all points a black border
 )
 
+# Aside: For "bubble" plots, pass an appropriate vector to the `cex` arg.
+# This can be useful for depicting an additional dimension of the data (here:
+# Wind).
+tinyplot(
+  Temp ~ Day | Month,
+  data = aq,
+  pch = 21,
+  cex = aq$Wind, # map character size to another feature in the data
+  bg = 0.3,
+  col = "black"
+)
+
 # Converting to a grouped line plot is a simple matter of adjusting the
 # `type` argument.
 

From 92f2d74ce837e8b8eb56a8fbdbdc133235e517fc Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Mon, 21 Jul 2025 21:11:59 -0700
Subject: [PATCH 22/29] capture bubble pch and transparency early

---
 R/tinyplot.R | 22 ++++++++++++++++------
 1 file changed, 16 insertions(+), 6 deletions(-)

diff --git a/R/tinyplot.R b/R/tinyplot.R
index f87d1df4..75abbf02 100644
--- a/R/tinyplot.R
+++ b/R/tinyplot.R
@@ -959,6 +959,16 @@ tinyplot.default = function(
     split_data = list(as.list(datapoints))
   }
 
+
+  # catch some simple aesthetics for bubble plots before the standard "by"
+  # grouping sanitizers (actually: will only be used for dual_legend plots but
+  # easiest to assign/determine now)
+  if (bubble) {
+    bubble_pch = if (!is.null(pch) && length(pch)==1) pch else par("pch")
+    bubble_alpha = if (!is.null(alpha)) alpha else 1
+    bubble_bg_alpha = if (!is.null(bg) && length(bg)==1 && is.numeric(bg) && bg > 0 && bg <=1) bg else 1
+  }
+  
   # aesthetics by group: col, bg, etc.
   ngrps = if (null_by) 1L else if (is.factor(by)) length(levels(by)) else if (by_continuous) 100L else length(unique(by))
   pch = by_pch(ngrps = ngrps, type = type, pch = pch)
@@ -1177,11 +1187,11 @@ tinyplot.default = function(
           ),
           lgnd_labs = names(bubble_cex),
           type = type,
-          pch = par("pch"),
+          pch = bubble_pch,
           lty = lty,
           lwd = lwd,
-          col = par("col"),
-          bg = par("col"),
+          col = adjustcolor(par("col"), alpha.f = bubble_alpha),
+          bg = adjustcolor(par("col"), alpha.f = bubble_bg_alpha),
           cex = bubble_cex * cex_fct_adj,
           has_sub = has_sub
         )
@@ -1246,11 +1256,11 @@ tinyplot.default = function(
           ),
           lgnd_labs = names(bubble_cex),
           type = type,
-          pch = par("pch"),
+          pch = bubble_pch,
           lty = lty,
           lwd = lwd,
-          col = par("col"),
-          bg = par("col"),
+          col = adjustcolor(par("col"), alpha.f = bubble_alpha),
+          bg = adjustcolor(par("col"), alpha.f = bubble_bg_alpha),
           # gradient = by_continuous,
           cex = bubble_cex * cex_fct_adj,
           has_sub = has_sub,

From dccd9ad78aaddbd4a6aa0920298379080db1d879 Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Tue, 22 Jul 2025 10:37:36 -0700
Subject: [PATCH 23/29] x/y.intersp adjustment for custom clim max

---
 R/tinyplot.R    | 31 ++++++++++++++++++++++---------
 R/type_points.R |  9 +++++++--
 R/type_text.R   |  6 +++++-
 3 files changed, 34 insertions(+), 12 deletions(-)

diff --git a/R/tinyplot.R b/R/tinyplot.R
index 75abbf02..92d45bf8 100644
--- a/R/tinyplot.R
+++ b/R/tinyplot.R
@@ -675,6 +675,14 @@ tinyplot.default = function(
   # check flip flag is logical 
   assert_flag(flip)
 
+  # legend prep
+  if (!exists("legend_args")) {
+    legend_args = dots[["legend_args"]]
+    dots[["legend_args"]] = NULL
+  }
+  if (is.null(legend_args)) legend_args = list(x = NULL)
+  legend = substitute(legend)
+
   palette = substitute(palette)
 
   # themes
@@ -842,6 +850,7 @@ tinyplot.default = function(
       facet        = facet,
       facet_by     = facet_by,
       facet.args   = facet.args,
+      legend_args  = legend_args,
       null_by      = null_by,
       null_facet   = null_facet,
       palette      = palette,
@@ -1025,12 +1034,6 @@ tinyplot.default = function(
   dual_legend = bubble && !null_by && !isFALSE(legend)
   lgnd_cex = NULL
 
-  if (!exists("legend_args")) {
-    legend_args = dots[["legend_args"]]
-  }
-  if (is.null(legend_args)) legend_args = list(x = NULL)
-  legend = substitute(legend)
-
   if (isFALSE(legend)) {
     legend = "none"
   } else if (isTRUE(legend)) {
@@ -1121,7 +1124,11 @@ tinyplot.default = function(
       # legend 1: by grouping
       lgby = draw_legend(
         legend = lgby_pos,
-        legend_args = legend_args,
+        legend_args = modifyList(
+          legend_args,
+          list(x.intersp = 1, y.intersp = 1),
+          keep.null = TRUE
+        ),
         by_dep = by_dep,
         lgnd_labs = lgnd_labs,
         type = type,
@@ -1200,7 +1207,10 @@ tinyplot.default = function(
           legend = lgby_pos,
           legend_args = modifyList(
             legend_args,
-            list(inset = c(0, lgby_inset + 0.01)),
+            list(
+              inset = c(0, lgby_inset + 0.01),
+              x.intersp = 1, y.intersp = 1
+            ),
             keep.null = TRUE
           ),
           by_dep = by_dep,
@@ -1226,7 +1236,10 @@ tinyplot.default = function(
           legend = lgby_pos,
           legend_args = modifyList(
             legend_args,
-            list(inset = c((lgby_w-lgbub_w)/2, lgby_inset + 0.01)),
+            list(
+              inset = c((lgby_w-lgbub_w)/2, lgby_inset + 0.01),
+              x.intersp = 1, y.intersp = 1
+            ),
             keep.null = TRUE
           ),
           by_dep = by_dep,
diff --git a/R/type_points.R b/R/type_points.R
index 9fbcd22d..b664daa1 100644
--- a/R/type_points.R
+++ b/R/type_points.R
@@ -33,7 +33,7 @@ type_points = function(clim = c(0.5, 2.5)) {
 }
 
 data_points = function(clim = c(0.5, 2.5)) {
-  fun = function(datapoints, cex = NULL, ...) {
+  fun = function(datapoints, legend_args, cex = NULL, ...) {
     # catch for factors (we should still be able to "force" plot these with points)
     if (is.factor(datapoints$x)) {
       xlvls = levels(datapoints$x)
@@ -63,6 +63,10 @@ data_points = function(clim = c(0.5, 2.5)) {
       bubble_cex = cex[1:len_labs]
       cex = cex[(len_labs+1):length(cex)]
       names(bubble_cex) = format(bubble_labs)
+      if (max(clim) > 2.5) {
+        legend_args[["x.intersp"]] = max(clim) / 2.5
+        legend_args[["y.intersp"]] = sapply(bubble_cex / 2.5, max, 1)
+      }
     }
     
     out = list(
@@ -71,7 +75,8 @@ data_points = function(clim = c(0.5, 2.5)) {
       ylabs = ylabs,
       cex = cex,
       bubble = bubble,
-      bubble_cex = bubble_cex
+      bubble_cex = bubble_cex,
+      legend_args = legend_args
     )
     return(out)
   }
diff --git a/R/type_text.R b/R/type_text.R
index 398478d4..39a2d72c 100644
--- a/R/type_text.R
+++ b/R/type_text.R
@@ -45,7 +45,7 @@ type_text = function(labels, adj = NULL, pos = NULL, offset = 0.5, vfont = NULL,
 }
 
 data_text = function(labels, clim = c(0.5, 2.5)) {
-  fun = function(datapoints, cex = NULL, ...) {
+  fun = function(datapoints, legend_args, cex = NULL, ...) {
     if (length(labels) != 1 && length(labels) != nrow(datapoints)) {
       msg <- sprintf("`labels` must be of length 1 or %s.", nrow(datapoints))
       stop(msg, call. = FALSE)
@@ -65,6 +65,10 @@ data_text = function(labels, clim = c(0.5, 2.5)) {
       bubble_cex = cex[1:len_labs]
       cex = cex[(len_labs+1):length(cex)]
       names(bubble_cex) = format(bubble_labs)
+      if (max(clim) > 2.5) {
+        legend_args[["x.intersp"]] = max(clim) / 2.5
+        legend_args[["y.intersp"]] = sapply(bubble_cex / 2.5, max, 1)
+      }
     }
 
     out = list(

From d5d39442f493973ac0dda311274c804845e9842d Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Tue, 22 Jul 2025 15:06:31 -0700
Subject: [PATCH 24/29] docs

---
 R/type_points.R    | 10 ++++++++++
 man/type_points.Rd | 10 ++++++++++
 2 files changed, 20 insertions(+)

diff --git a/R/type_points.R b/R/type_points.R
index b664daa1..8b18a516 100644
--- a/R/type_points.R
+++ b/R/type_points.R
@@ -21,6 +21,16 @@
 #' # Continuous grouping (with gradient legend)
 #' tinyplot(Sepal.Length ~ Petal.Length | Sepal.Width, data = iris, pch = 19)
 #' 
+#' # Bubble chart version
+#' tinyplot(Sepal.Length ~ Petal.Length, data = iris, cex = iris$Sepal.Width)
+#' 
+#' # Fancier version with dual legends and extra customization
+#' tinyplot(Sepal.Length ~ Petal.Length | Species,
+#'          data = iris,
+#'          cex = iris$Sepal.Width, clim = c(1, 5),
+#'          pch = 21, fill = 0.3)
+#' 
+#' 
 #' @export
 type_points = function(clim = c(0.5, 2.5)) {
   out = list(
diff --git a/man/type_points.Rd b/man/type_points.Rd
index fed3f61e..3e9d1745 100644
--- a/man/type_points.Rd
+++ b/man/type_points.Rd
@@ -30,4 +30,14 @@ tinyplot(Sepal.Length ~ Petal.Length | Species, data = iris)
 # Continuous grouping (with gradient legend)
 tinyplot(Sepal.Length ~ Petal.Length | Sepal.Width, data = iris, pch = 19)
 
+# Bubble chart version
+tinyplot(Sepal.Length ~ Petal.Length, data = iris, cex = iris$Sepal.Width)
+
+# Fancier version with dual legends and extra customization
+tinyplot(Sepal.Length ~ Petal.Length | Species,
+         data = iris,
+         cex = iris$Sepal.Width, clim = c(1, 5),
+         pch = 21, fill = 0.3)
+
+
 }

From 5626d553dad56c498b0d51e1302e75a255d1b1a0 Mon Sep 17 00:00:00 2001
From: Grant McDermott <grant.mcdermott@gmail.com>
Date: Tue, 22 Jul 2025 15:25:10 -0700
Subject: [PATCH 25/29] add tests

---
 inst/tinytest/_tinysnapshot/bubble_cex_by.svg | 239 ++++++++++++++++
 .../_tinysnapshot/bubble_dual_continuous.svg  | 266 ++++++++++++++++++
 .../_tinysnapshot/bubble_dual_discrete.svg    | 261 +++++++++++++++++
 .../_tinysnapshot/bubble_dual_fancy.svg       | 266 ++++++++++++++++++
 inst/tinytest/_tinysnapshot/bubble_simple.svg | 245 ++++++++++++++++
 .../_tinysnapshot/bubble_simple_bottom.svg    | 245 ++++++++++++++++
 .../_tinysnapshot/bubble_simple_clim.svg      | 245 ++++++++++++++++
 inst/tinytest/test-bubble.R                   |  70 +++++
 8 files changed, 1837 insertions(+)
 create mode 100644 inst/tinytest/_tinysnapshot/bubble_cex_by.svg
 create mode 100644 inst/tinytest/_tinysnapshot/bubble_dual_continuous.svg
 create mode 100644 inst/tinytest/_tinysnapshot/bubble_dual_discrete.svg
 create mode 100644 inst/tinytest/_tinysnapshot/bubble_dual_fancy.svg
 create mode 100644 inst/tinytest/_tinysnapshot/bubble_simple.svg
 create mode 100644 inst/tinytest/_tinysnapshot/bubble_simple_bottom.svg
 create mode 100644 inst/tinytest/_tinysnapshot/bubble_simple_clim.svg
 create mode 100644 inst/tinytest/test-bubble.R

diff --git a/inst/tinytest/_tinysnapshot/bubble_cex_by.svg b/inst/tinytest/_tinysnapshot/bubble_cex_by.svg
new file mode 100644
index 00000000..999f1da6
--- /dev/null
+++ b/inst/tinytest/_tinysnapshot/bubble_cex_by.svg
@@ -0,0 +1,239 @@
+<?xml version='1.0' encoding='UTF-8' ?>
+<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='504.00pt' height='504.00pt' viewBox='0 0 504.00 504.00'>
+<g class='svglite'>
+<defs>
+  <style type='text/css'><![CDATA[
+    .svglite line, .svglite polyline, .svglite polygon, .svglite path, .svglite rect, .svglite circle {
+      fill: none;
+      stroke: #000000;
+      stroke-linecap: round;
+      stroke-linejoin: round;
+      stroke-miterlimit: 10.00;
+    }
+    .svglite text {
+      white-space: pre;
+    }
+    .svglite g.glyphgroup path {
+      fill: inherit;
+      stroke: none;
+    }
+  ]]></style>
+</defs>
+<rect width='100%' height='100%' style='stroke: none; fill: #FFFFFF;'/>
+<defs>
+  <clipPath id='cpMC4wMHw1MDQuMDB8MC4wMHw1MDQuMDA='>
+    <rect x='0.00' y='0.00' width='504.00' height='504.00' />
+  </clipPath>
+</defs>
+<g clip-path='url(#cpMC4wMHw1MDQuMDB8MC4wMHw1MDQuMDA=)'>
+<circle cx='435.00' cy='237.60' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='435.00' cy='252.00' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='435.00' cy='266.40' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<text x='463.38' y='223.20' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='42.69px' lengthAdjust='spacingAndGlyphs'>Species</text>
+<text x='445.80' y='241.73' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='35.34px' lengthAdjust='spacingAndGlyphs'>setosa</text>
+<text x='445.80' y='256.13' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='51.36px' lengthAdjust='spacingAndGlyphs'>versicolor</text>
+<text x='445.80' y='270.53' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='44.03px' lengthAdjust='spacingAndGlyphs'>virginica</text>
+</g>
+<defs>
+  <clipPath id='cpMC4wMHw0MDkuODB8MC4wMHw1MDQuMDA='>
+    <rect x='0.00' y='0.00' width='409.80' height='504.00' />
+  </clipPath>
+</defs>
+<g clip-path='url(#cpMC4wMHw0MDkuODB8MC4wMHw1MDQuMDA=)'>
+<text x='234.42' y='485.28' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='67.36px' lengthAdjust='spacingAndGlyphs'>Petal.Length</text>
+<text transform='translate(12.96,244.80) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='70.70px' lengthAdjust='spacingAndGlyphs'>Sepal.Length</text>
+</g>
+<g clip-path='url(#cpMC4wMHw1MDQuMDB8MC4wMHw1MDQuMDA=)'>
+<line x1='72.03' y1='430.56' x2='402.31' y2='430.56' style='stroke-width: 0.75;' />
+<line x1='72.03' y1='430.56' x2='72.03' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='127.08' y1='430.56' x2='127.08' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='182.13' y1='430.56' x2='182.13' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='237.17' y1='430.56' x2='237.17' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='292.22' y1='430.56' x2='292.22' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='347.27' y1='430.56' x2='347.27' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='402.31' y1='430.56' x2='402.31' y2='437.76' style='stroke-width: 0.75;' />
+<text x='72.03' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>1</text>
+<text x='127.08' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>2</text>
+<text x='182.13' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>3</text>
+<text x='237.17' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>4</text>
+<text x='292.22' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>5</text>
+<text x='347.27' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>6</text>
+<text x='402.31' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>7</text>
+<line x1='59.04' y1='397.69' x2='59.04' y2='63.24' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='397.69' x2='51.84' y2='397.69' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='349.91' x2='51.84' y2='349.91' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='302.13' x2='51.84' y2='302.13' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='254.36' x2='51.84' y2='254.36' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='206.58' x2='51.84' y2='206.58' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='158.80' x2='51.84' y2='158.80' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='111.02' x2='51.84' y2='111.02' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='63.24' x2='51.84' y2='63.24' style='stroke-width: 0.75;' />
+<text transform='translate(41.76,397.69) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>4.5</text>
+<text transform='translate(41.76,349.91) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>5.0</text>
+<text transform='translate(41.76,302.13) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>5.5</text>
+<text transform='translate(41.76,254.36) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>6.0</text>
+<text transform='translate(41.76,206.58) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>6.5</text>
+<text transform='translate(41.76,158.80) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>7.0</text>
+<text transform='translate(41.76,111.02) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>7.5</text>
+<text transform='translate(41.76,63.24) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>8.0</text>
+<polygon points='59.04,430.56 409.80,430.56 409.80,59.04 59.04,59.04 ' style='stroke-width: 0.75;' />
+</g>
+<defs>
+  <clipPath id='cpNTkuMDR8NDA5LjgwfDU5LjA0fDQzMC41Ng=='>
+    <rect x='59.04' y='59.04' width='350.76' height='371.52' />
+  </clipPath>
+</defs>
+<g clip-path='url(#cpNTkuMDR8NDA5LjgwfDU5LjA0fDQzMC41Ng==)'>
+<circle cx='94.05' cy='340.36' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='94.05' cy='359.47' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='88.55' cy='378.58' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='99.55' cy='388.13' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='94.05' cy='349.91' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='110.56' cy='311.69' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='94.05' cy='388.13' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='99.55' cy='349.91' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='94.05' cy='407.24' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='99.55' cy='359.47' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='99.55' cy='311.69' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='105.06' cy='369.02' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='94.05' cy='369.02' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='77.54' cy='416.80' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='83.04' cy='273.47' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='99.55' cy='283.02' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='88.55' cy='311.69' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='94.05' cy='340.36' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='110.56' cy='283.02' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='99.55' cy='340.36' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='110.56' cy='311.69' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='99.55' cy='340.36' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='72.03' cy='388.13' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='110.56' cy='340.36' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='121.57' cy='369.02' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='105.06' cy='349.91' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='105.06' cy='349.91' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='99.55' cy='330.80' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='94.05' cy='330.80' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='105.06' cy='378.58' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='105.06' cy='369.02' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='99.55' cy='311.69' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='99.55' cy='330.80' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='94.05' cy='302.13' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='99.55' cy='359.47' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='83.04' cy='349.91' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='88.55' cy='302.13' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='94.05' cy='359.47' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='88.55' cy='407.24' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='99.55' cy='340.36' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='88.55' cy='349.91' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='88.55' cy='397.69' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='88.55' cy='407.24' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='105.06' cy='349.91' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='121.57' cy='340.36' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='94.05' cy='369.02' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='105.06' cy='340.36' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='94.05' cy='388.13' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='99.55' cy='321.24' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='94.05' cy='349.91' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='275.71' cy='158.80' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='264.70' cy='216.13' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='286.72' cy='168.36' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='237.17' cy='302.13' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='270.20' cy='206.58' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='264.70' cy='283.02' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='275.71' cy='225.69' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='198.64' cy='359.47' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='270.20' cy='197.02' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='231.67' cy='330.80' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='209.65' cy='349.91' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='248.18' cy='263.91' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='237.17' cy='254.36' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='275.71' cy='244.80' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='215.15' cy='292.58' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='259.19' cy='187.47' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='264.70' cy='292.58' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='242.68' cy='273.47' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='264.70' cy='235.24' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='231.67' cy='292.58' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='281.21' cy='263.91' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='237.17' cy='244.80' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='286.72' cy='225.69' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='275.71' cy='244.80' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='253.69' cy='216.13' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='259.19' cy='197.02' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='281.21' cy='177.91' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='292.22' cy='187.47' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='264.70' cy='254.36' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='209.65' cy='283.02' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='226.16' cy='302.13' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='220.66' cy='302.13' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='231.67' cy='273.47' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='297.72' cy='254.36' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='264.70' cy='311.69' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='264.70' cy='254.36' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='275.71' cy='187.47' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='259.19' cy='225.69' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='242.68' cy='292.58' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='237.17' cy='302.13' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='259.19' cy='302.13' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='270.20' cy='244.80' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='237.17' cy='273.47' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='198.64' cy='349.91' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='248.18' cy='292.58' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='248.18' cy='283.02' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='248.18' cy='283.02' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='253.69' cy='235.24' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='182.13' cy='340.36' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='242.68' cy='283.02' r='4.72' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='347.27' cy='225.69' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='297.72' cy='273.47' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='341.76' cy='149.24' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='325.25' cy='225.69' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='336.26' cy='206.58' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='380.30' cy='101.47' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='264.70' cy='359.47' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='363.78' cy='130.13' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='336.26' cy='187.47' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='352.77' cy='139.69' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='297.72' cy='206.58' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='308.73' cy='216.13' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='319.74' cy='177.91' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='292.22' cy='283.02' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='297.72' cy='273.47' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='308.73' cy='216.13' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='319.74' cy='206.58' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='385.80' cy='91.91' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='396.81' cy='91.91' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='292.22' cy='254.36' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='330.75' cy='168.36' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='286.72' cy='292.58' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='385.80' cy='91.91' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='286.72' cy='225.69' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='330.75' cy='187.47' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='347.27' cy='139.69' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='281.21' cy='235.24' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='286.72' cy='244.80' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='325.25' cy='216.13' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='336.26' cy='139.69' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='352.77' cy='120.58' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='369.29' cy='72.80' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='325.25' cy='216.13' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='297.72' cy='225.69' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='325.25' cy='244.80' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='352.77' cy='91.91' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='325.25' cy='225.69' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='319.74' cy='216.13' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='281.21' cy='254.36' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='314.24' cy='168.36' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='325.25' cy='187.47' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='297.72' cy='168.36' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='297.72' cy='273.47' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='341.76' cy='177.91' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='330.75' cy='187.47' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='303.23' cy='187.47' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='292.22' cy='225.69' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='303.23' cy='206.58' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='314.24' cy='235.24' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='297.72' cy='263.91' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+</g>
+</g>
+</svg>
diff --git a/inst/tinytest/_tinysnapshot/bubble_dual_continuous.svg b/inst/tinytest/_tinysnapshot/bubble_dual_continuous.svg
new file mode 100644
index 00000000..ea394222
--- /dev/null
+++ b/inst/tinytest/_tinysnapshot/bubble_dual_continuous.svg
@@ -0,0 +1,266 @@
+<?xml version='1.0' encoding='UTF-8' ?>
+<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='504.00pt' height='504.00pt' viewBox='0 0 504.00 504.00'>
+<g class='svglite'>
+<defs>
+  <style type='text/css'><![CDATA[
+    .svglite line, .svglite polyline, .svglite polygon, .svglite path, .svglite rect, .svglite circle {
+      fill: none;
+      stroke: #000000;
+      stroke-linecap: round;
+      stroke-linejoin: round;
+      stroke-miterlimit: 10.00;
+    }
+    .svglite text {
+      white-space: pre;
+    }
+    .svglite g.glyphgroup path {
+      fill: inherit;
+      stroke: none;
+    }
+  ]]></style>
+</defs>
+<rect width='100%' height='100%' style='stroke: none; fill: #FFFFFF;'/>
+<defs>
+  <clipPath id='cpMC4wMHw1MDQuMDB8MC4wMHw1MDQuMDA='>
+    <rect x='0.00' y='0.00' width='504.00' height='504.00' />
+  </clipPath>
+</defs>
+<g clip-path='url(#cpMC4wMHw1MDQuMDB8MC4wMHw1MDQuMDA=)'>
+</g>
+<defs>
+  <clipPath id='cpNTkuMDR8NTA0LjAwfDU5LjA0fDQzMC41Ng=='>
+    <rect x='59.04' y='59.04' width='444.96' height='371.52' />
+  </clipPath>
+</defs>
+<g clip-path='url(#cpNTkuMDR8NTA0LjAwfDU5LjA0fDQzMC41Ng==)'>
+</g>
+<g clip-path='url(#cpMC4wMHw1MDQuMDB8MC4wMHw1MDQuMDA=)'>
+<image width='18.00' height='86.40' x='431.60' y='151.52' preserveAspectRatio='none' xlink:href='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAABkCAYAAABHLFpgAAAAo0lEQVQYlXWLS1LDQAwFW62yA2GRKk7BQTgPx8+CzGhYYDtxPptX/V5LfH/9DCsDS3GkWCkOY6FKsWdgVywDW4pN8ZKBF8XfBM8pnhVbBuIWd/U/2Il8JWQvWCliV29ivNrG4xabyIHEE2FtH/Uo4glZCFexbmHhKQo/o/AUHT8sPEbhexS+2fEQHQ9RC803tNbZhjO1bNMmpmg4ca0dp1ju/gCmb0eaNdB1fgAAAABJRU5ErkJggg=='/>
+<text x='449.60' y='242.05' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='20.00px' lengthAdjust='spacingAndGlyphs'> 2.0</text>
+<text x='449.60' y='223.72' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='20.00px' lengthAdjust='spacingAndGlyphs'> 2.5</text>
+<text x='449.60' y='206.27' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='20.00px' lengthAdjust='spacingAndGlyphs'> 3.0</text>
+<text x='449.60' y='187.94' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='20.00px' lengthAdjust='spacingAndGlyphs'> 3.5</text>
+<text x='449.60' y='170.49' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='20.00px' lengthAdjust='spacingAndGlyphs'> 4.0</text>
+<text x='449.60' y='242.05' text-anchor='end' style='font-size: 12.00px;fill: #FFFFFF; font-family: "Liberation Sans";' textLength='17.98px' lengthAdjust='spacingAndGlyphs'>-   -</text>
+<text x='449.60' y='223.72' text-anchor='end' style='font-size: 12.00px;fill: #FFFFFF; font-family: "Liberation Sans";' textLength='17.98px' lengthAdjust='spacingAndGlyphs'>-   -</text>
+<text x='449.60' y='206.27' text-anchor='end' style='font-size: 12.00px;fill: #FFFFFF; font-family: "Liberation Sans";' textLength='17.98px' lengthAdjust='spacingAndGlyphs'>-   -</text>
+<text x='449.60' y='187.94' text-anchor='end' style='font-size: 12.00px;fill: #FFFFFF; font-family: "Liberation Sans";' textLength='17.98px' lengthAdjust='spacingAndGlyphs'>-   -</text>
+<text x='449.60' y='170.49' text-anchor='end' style='font-size: 12.00px;fill: #FFFFFF; font-family: "Liberation Sans";' textLength='17.98px' lengthAdjust='spacingAndGlyphs'>-   -</text>
+<text x='431.60' y='137.12' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='64.69px' lengthAdjust='spacingAndGlyphs'>Sepal.Width</text>
+<circle cx='447.29' cy='277.32' r='1.35' style='stroke-width: 0.75;' />
+<circle cx='447.29' cy='291.72' r='3.76' style='stroke-width: 0.75;' />
+<circle cx='447.29' cy='306.12' r='4.77' style='stroke-width: 0.75;' />
+<circle cx='447.29' cy='320.52' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='447.29' cy='334.92' r='6.18' style='stroke-width: 0.75;' />
+<circle cx='447.29' cy='349.32' r='6.75' style='stroke-width: 0.75;' />
+<text x='458.33' y='262.92' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='83.36px' lengthAdjust='spacingAndGlyphs'>iris$Petal.Width</text>
+<text x='458.09' y='281.44' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>0.0</text>
+<text x='458.09' y='295.84' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>0.5</text>
+<text x='458.09' y='310.24' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>1.0</text>
+<text x='458.09' y='324.64' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>1.5</text>
+<text x='458.09' y='339.04' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>2.0</text>
+<text x='458.09' y='353.44' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>2.5</text>
+</g>
+<defs>
+  <clipPath id='cpMC4wMHwzOTkuNTV8MC4wMHw1MDQuMDA='>
+    <rect x='0.00' y='0.00' width='399.55' height='504.00' />
+  </clipPath>
+</defs>
+<g clip-path='url(#cpMC4wMHwzOTkuNTV8MC4wMHw1MDQuMDA=)'>
+<text x='229.29' y='485.28' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='67.36px' lengthAdjust='spacingAndGlyphs'>Petal.Length</text>
+<text transform='translate(12.96,244.80) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='70.70px' lengthAdjust='spacingAndGlyphs'>Sepal.Length</text>
+</g>
+<g clip-path='url(#cpMC4wMHw1MDQuMDB8MC4wMHw1MDQuMDA=)'>
+<line x1='71.65' y1='430.56' x2='392.28' y2='430.56' style='stroke-width: 0.75;' />
+<line x1='71.65' y1='430.56' x2='71.65' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='125.09' y1='430.56' x2='125.09' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='178.53' y1='430.56' x2='178.53' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='231.97' y1='430.56' x2='231.97' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='285.40' y1='430.56' x2='285.40' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='338.84' y1='430.56' x2='338.84' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='392.28' y1='430.56' x2='392.28' y2='437.76' style='stroke-width: 0.75;' />
+<text x='71.65' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>1</text>
+<text x='125.09' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>2</text>
+<text x='178.53' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>3</text>
+<text x='231.97' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>4</text>
+<text x='285.40' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>5</text>
+<text x='338.84' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>6</text>
+<text x='392.28' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>7</text>
+<line x1='59.04' y1='397.69' x2='59.04' y2='63.24' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='397.69' x2='51.84' y2='397.69' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='349.91' x2='51.84' y2='349.91' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='302.13' x2='51.84' y2='302.13' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='254.36' x2='51.84' y2='254.36' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='206.58' x2='51.84' y2='206.58' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='158.80' x2='51.84' y2='158.80' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='111.02' x2='51.84' y2='111.02' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='63.24' x2='51.84' y2='63.24' style='stroke-width: 0.75;' />
+<text transform='translate(41.76,397.69) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>4.5</text>
+<text transform='translate(41.76,349.91) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>5.0</text>
+<text transform='translate(41.76,302.13) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>5.5</text>
+<text transform='translate(41.76,254.36) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>6.0</text>
+<text transform='translate(41.76,206.58) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>6.5</text>
+<text transform='translate(41.76,158.80) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>7.0</text>
+<text transform='translate(41.76,111.02) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>7.5</text>
+<text transform='translate(41.76,63.24) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>8.0</text>
+<polygon points='59.04,430.56 399.55,430.56 399.55,59.04 59.04,59.04 ' style='stroke-width: 0.75;' />
+</g>
+<defs>
+  <clipPath id='cpNTkuMDR8Mzk5LjU1fDU5LjA0fDQzMC41Ng=='>
+    <rect x='59.04' y='59.04' width='340.51' height='371.52' />
+  </clipPath>
+</defs>
+<g clip-path='url(#cpNTkuMDR8Mzk5LjU1fDU5LjA0fDQzMC41Ng==)'>
+<circle cx='93.03' cy='340.36' r='2.88' style='stroke-width: 0.75; stroke: #008297;' />
+<circle cx='93.03' cy='359.47' r='2.88' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='87.68' cy='378.58' r='2.88' style='stroke-width: 0.75; stroke: #009C95;' />
+<circle cx='98.37' cy='388.13' r='2.88' style='stroke-width: 0.75; stroke: #00A491;' />
+<circle cx='93.03' cy='349.91' r='2.88' style='stroke-width: 0.75; stroke: #007996;' />
+<circle cx='109.06' cy='311.69' r='3.51' style='stroke-width: 0.75; stroke: #005E8D;' />
+<circle cx='93.03' cy='388.13' r='3.22' style='stroke-width: 0.75; stroke: #008A98;' />
+<circle cx='98.37' cy='349.91' r='2.88' style='stroke-width: 0.75; stroke: #008A98;' />
+<circle cx='93.03' cy='407.24' r='2.88' style='stroke-width: 0.75; stroke: #00B189;' />
+<circle cx='98.37' cy='359.47' r='2.43' style='stroke-width: 0.75; stroke: #00A491;' />
+<circle cx='98.37' cy='311.69' r='2.88' style='stroke-width: 0.75; stroke: #007094;' />
+<circle cx='103.71' cy='369.02' r='2.88' style='stroke-width: 0.75; stroke: #008A98;' />
+<circle cx='93.03' cy='369.02' r='2.43' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='77.00' cy='416.80' r='2.43' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='82.34' cy='273.47' r='2.88' style='stroke-width: 0.75; stroke: #045489;' />
+<circle cx='98.37' cy='283.02' r='3.51' style='stroke-width: 0.75; stroke: #44286E;' />
+<circle cx='87.68' cy='311.69' r='3.51' style='stroke-width: 0.75; stroke: #005E8D;' />
+<circle cx='93.03' cy='340.36' r='3.22' style='stroke-width: 0.75; stroke: #008297;' />
+<circle cx='109.06' cy='283.02' r='3.22' style='stroke-width: 0.75; stroke: #006690;' />
+<circle cx='98.37' cy='340.36' r='3.22' style='stroke-width: 0.75; stroke: #006690;' />
+<circle cx='109.06' cy='311.69' r='2.88' style='stroke-width: 0.75; stroke: #008A98;' />
+<circle cx='98.37' cy='340.36' r='3.51' style='stroke-width: 0.75; stroke: #007094;' />
+<circle cx='71.65' cy='388.13' r='2.88' style='stroke-width: 0.75; stroke: #007996;' />
+<circle cx='109.06' cy='340.36' r='3.76' style='stroke-width: 0.75; stroke: #009297;' />
+<circle cx='119.75' cy='369.02' r='2.88' style='stroke-width: 0.75; stroke: #008A98;' />
+<circle cx='103.71' cy='349.91' r='2.88' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='103.71' cy='349.91' r='3.51' style='stroke-width: 0.75; stroke: #008A98;' />
+<circle cx='98.37' cy='330.80' r='2.88' style='stroke-width: 0.75; stroke: #008297;' />
+<circle cx='93.03' cy='330.80' r='2.88' style='stroke-width: 0.75; stroke: #008A98;' />
+<circle cx='103.71' cy='378.58' r='2.88' style='stroke-width: 0.75; stroke: #009C95;' />
+<circle cx='103.71' cy='369.02' r='2.88' style='stroke-width: 0.75; stroke: #00A491;' />
+<circle cx='98.37' cy='311.69' r='3.51' style='stroke-width: 0.75; stroke: #008A98;' />
+<circle cx='98.37' cy='330.80' r='2.43' style='stroke-width: 0.75; stroke: #284782;' />
+<circle cx='93.03' cy='302.13' r='2.88' style='stroke-width: 0.75; stroke: #353E7B;' />
+<circle cx='98.37' cy='359.47' r='2.88' style='stroke-width: 0.75; stroke: #00A491;' />
+<circle cx='82.34' cy='349.91' r='2.88' style='stroke-width: 0.75; stroke: #009C95;' />
+<circle cx='87.68' cy='302.13' r='2.88' style='stroke-width: 0.75; stroke: #008297;' />
+<circle cx='93.03' cy='359.47' r='2.43' style='stroke-width: 0.75; stroke: #007996;' />
+<circle cx='87.68' cy='407.24' r='2.88' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='98.37' cy='340.36' r='2.88' style='stroke-width: 0.75; stroke: #008A98;' />
+<circle cx='87.68' cy='349.91' r='3.22' style='stroke-width: 0.75; stroke: #008297;' />
+<circle cx='87.68' cy='397.69' r='3.22' style='stroke-width: 0.75; stroke: #7FD355;' />
+<circle cx='87.68' cy='407.24' r='2.88' style='stroke-width: 0.75; stroke: #009C95;' />
+<circle cx='103.71' cy='349.91' r='4.00' style='stroke-width: 0.75; stroke: #008297;' />
+<circle cx='119.75' cy='340.36' r='3.51' style='stroke-width: 0.75; stroke: #006690;' />
+<circle cx='93.03' cy='369.02' r='3.22' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='103.71' cy='340.36' r='2.88' style='stroke-width: 0.75; stroke: #006690;' />
+<circle cx='93.03' cy='388.13' r='2.88' style='stroke-width: 0.75; stroke: #009C95;' />
+<circle cx='98.37' cy='321.24' r='2.88' style='stroke-width: 0.75; stroke: #007094;' />
+<circle cx='93.03' cy='349.91' r='2.88' style='stroke-width: 0.75; stroke: #009297;' />
+<circle cx='269.37' cy='158.80' r='5.39' style='stroke-width: 0.75; stroke: #009C95;' />
+<circle cx='258.68' cy='216.13' r='5.53' style='stroke-width: 0.75; stroke: #009C95;' />
+<circle cx='280.06' cy='168.36' r='5.53' style='stroke-width: 0.75; stroke: #00A491;' />
+<circle cx='231.97' cy='302.13' r='5.24' style='stroke-width: 0.75; stroke: #7FD355;' />
+<circle cx='264.03' cy='206.58' r='5.53' style='stroke-width: 0.75; stroke: #00B883;' />
+<circle cx='258.68' cy='283.02' r='5.24' style='stroke-width: 0.75; stroke: #00B883;' />
+<circle cx='269.37' cy='225.69' r='5.67' style='stroke-width: 0.75; stroke: #009297;' />
+<circle cx='194.56' cy='359.47' r='4.77' style='stroke-width: 0.75; stroke: #65CF60;' />
+<circle cx='264.03' cy='197.02' r='5.24' style='stroke-width: 0.75; stroke: #00B189;' />
+<circle cx='226.62' cy='330.80' r='5.39' style='stroke-width: 0.75; stroke: #00BD7D;' />
+<circle cx='205.25' cy='349.91' r='4.77' style='stroke-width: 0.75; stroke: #C2DE34;' />
+<circle cx='242.65' cy='263.91' r='5.53' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='231.97' cy='254.36' r='4.77' style='stroke-width: 0.75; stroke: #97D74A;' />
+<circle cx='269.37' cy='244.80' r='5.39' style='stroke-width: 0.75; stroke: #00B189;' />
+<circle cx='210.59' cy='292.58' r='5.24' style='stroke-width: 0.75; stroke: #00B189;' />
+<circle cx='253.34' cy='187.47' r='5.39' style='stroke-width: 0.75; stroke: #00A491;' />
+<circle cx='258.68' cy='292.58' r='5.53' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='237.31' cy='273.47' r='4.77' style='stroke-width: 0.75; stroke: #00BD7D;' />
+<circle cx='258.68' cy='235.24' r='5.53' style='stroke-width: 0.75; stroke: #97D74A;' />
+<circle cx='226.62' cy='292.58' r='4.93' style='stroke-width: 0.75; stroke: #3CC96C;' />
+<circle cx='274.72' cy='263.91' r='5.93' style='stroke-width: 0.75; stroke: #009C95;' />
+<circle cx='231.97' cy='244.80' r='5.24' style='stroke-width: 0.75; stroke: #00B883;' />
+<circle cx='280.06' cy='225.69' r='5.53' style='stroke-width: 0.75; stroke: #3CC96C;' />
+<circle cx='269.37' cy='244.80' r='5.09' style='stroke-width: 0.75; stroke: #00B883;' />
+<circle cx='248.00' cy='216.13' r='5.24' style='stroke-width: 0.75; stroke: #00B189;' />
+<circle cx='253.34' cy='197.02' r='5.39' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='274.72' cy='177.91' r='5.39' style='stroke-width: 0.75; stroke: #00B883;' />
+<circle cx='285.40' cy='187.47' r='5.80' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='258.68' cy='254.36' r='5.53' style='stroke-width: 0.75; stroke: #00B189;' />
+<circle cx='205.25' cy='283.02' r='4.77' style='stroke-width: 0.75; stroke: #01C475;' />
+<circle cx='221.28' cy='302.13' r='4.93' style='stroke-width: 0.75; stroke: #65CF60;' />
+<circle cx='215.93' cy='302.13' r='4.77' style='stroke-width: 0.75; stroke: #65CF60;' />
+<circle cx='226.62' cy='273.47' r='5.09' style='stroke-width: 0.75; stroke: #00BD7D;' />
+<circle cx='290.75' cy='254.36' r='5.67' style='stroke-width: 0.75; stroke: #00BD7D;' />
+<circle cx='258.68' cy='311.69' r='5.53' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='258.68' cy='254.36' r='5.67' style='stroke-width: 0.75; stroke: #008A98;' />
+<circle cx='269.37' cy='187.47' r='5.53' style='stroke-width: 0.75; stroke: #00A491;' />
+<circle cx='253.34' cy='225.69' r='5.24' style='stroke-width: 0.75; stroke: #7FD355;' />
+<circle cx='237.31' cy='292.58' r='5.24' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='231.97' cy='302.13' r='5.24' style='stroke-width: 0.75; stroke: #3CC96C;' />
+<circle cx='253.34' cy='302.13' r='5.09' style='stroke-width: 0.75; stroke: #01C475;' />
+<circle cx='264.03' cy='244.80' r='5.39' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='231.97' cy='273.47' r='5.09' style='stroke-width: 0.75; stroke: #01C475;' />
+<circle cx='194.56' cy='349.91' r='4.77' style='stroke-width: 0.75; stroke: #7FD355;' />
+<circle cx='242.65' cy='292.58' r='5.24' style='stroke-width: 0.75; stroke: #00BD7D;' />
+<circle cx='242.65' cy='283.02' r='5.09' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='242.65' cy='283.02' r='5.24' style='stroke-width: 0.75; stroke: #00B189;' />
+<circle cx='248.00' cy='235.24' r='5.24' style='stroke-width: 0.75; stroke: #00B189;' />
+<circle cx='178.53' cy='340.36' r='4.93' style='stroke-width: 0.75; stroke: #3CC96C;' />
+<circle cx='237.31' cy='283.02' r='5.24' style='stroke-width: 0.75; stroke: #00B883;' />
+<circle cx='338.84' cy='225.69' r='6.75' style='stroke-width: 0.75; stroke: #009297;' />
+<circle cx='290.75' cy='273.47' r='6.06' style='stroke-width: 0.75; stroke: #00BD7D;' />
+<circle cx='333.50' cy='149.24' r='6.30' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='317.47' cy='225.69' r='5.93' style='stroke-width: 0.75; stroke: #00B189;' />
+<circle cx='328.15' cy='206.58' r='6.42' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='370.91' cy='101.47' r='6.30' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='258.68' cy='359.47' r='5.80' style='stroke-width: 0.75; stroke: #3CC96C;' />
+<circle cx='354.87' cy='130.13' r='5.93' style='stroke-width: 0.75; stroke: #00B189;' />
+<circle cx='328.15' cy='187.47' r='5.93' style='stroke-width: 0.75; stroke: #3CC96C;' />
+<circle cx='344.19' cy='139.69' r='6.75' style='stroke-width: 0.75; stroke: #007996;' />
+<circle cx='290.75' cy='206.58' r='6.18' style='stroke-width: 0.75; stroke: #009C95;' />
+<circle cx='301.44' cy='216.13' r='6.06' style='stroke-width: 0.75; stroke: #00BD7D;' />
+<circle cx='312.12' cy='177.91' r='6.30' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='285.40' cy='283.02' r='6.18' style='stroke-width: 0.75; stroke: #3CC96C;' />
+<circle cx='290.75' cy='273.47' r='6.64' style='stroke-width: 0.75; stroke: #00B883;' />
+<circle cx='301.44' cy='216.13' r='6.53' style='stroke-width: 0.75; stroke: #009C95;' />
+<circle cx='312.12' cy='206.58' r='5.93' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='376.25' cy='91.91' r='6.42' style='stroke-width: 0.75; stroke: #006690;' />
+<circle cx='386.94' cy='91.91' r='6.53' style='stroke-width: 0.75; stroke: #01C475;' />
+<circle cx='285.40' cy='254.36' r='5.53' style='stroke-width: 0.75; stroke: #97D74A;' />
+<circle cx='322.81' cy='168.36' r='6.53' style='stroke-width: 0.75; stroke: #009C95;' />
+<circle cx='280.06' cy='292.58' r='6.18' style='stroke-width: 0.75; stroke: #00B883;' />
+<circle cx='376.25' cy='91.91' r='6.18' style='stroke-width: 0.75; stroke: #00B883;' />
+<circle cx='280.06' cy='225.69' r='5.93' style='stroke-width: 0.75; stroke: #00BD7D;' />
+<circle cx='322.81' cy='187.47' r='6.30' style='stroke-width: 0.75; stroke: #009297;' />
+<circle cx='338.84' cy='139.69' r='5.93' style='stroke-width: 0.75; stroke: #009C95;' />
+<circle cx='274.72' cy='235.24' r='5.93' style='stroke-width: 0.75; stroke: #00B883;' />
+<circle cx='280.06' cy='244.80' r='5.93' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='317.47' cy='216.13' r='6.30' style='stroke-width: 0.75; stroke: #00B883;' />
+<circle cx='328.15' cy='139.69' r='5.67' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='344.19' cy='120.58' r='6.06' style='stroke-width: 0.75; stroke: #00B883;' />
+<circle cx='360.22' cy='72.80' r='6.18' style='stroke-width: 0.75; stroke: #006690;' />
+<circle cx='317.47' cy='216.13' r='6.42' style='stroke-width: 0.75; stroke: #00B883;' />
+<circle cx='290.75' cy='225.69' r='5.53' style='stroke-width: 0.75; stroke: #00B883;' />
+<circle cx='317.47' cy='244.80' r='5.39' style='stroke-width: 0.75; stroke: #01C475;' />
+<circle cx='344.19' cy='91.91' r='6.53' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='317.47' cy='225.69' r='6.64' style='stroke-width: 0.75; stroke: #008A98;' />
+<circle cx='312.12' cy='216.13' r='5.93' style='stroke-width: 0.75; stroke: #00A491;' />
+<circle cx='274.72' cy='254.36' r='5.93' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='306.78' cy='168.36' r='6.30' style='stroke-width: 0.75; stroke: #00A491;' />
+<circle cx='317.47' cy='187.47' r='6.64' style='stroke-width: 0.75; stroke: #00A491;' />
+<circle cx='290.75' cy='168.36' r='6.53' style='stroke-width: 0.75; stroke: #00A491;' />
+<circle cx='290.75' cy='273.47' r='6.06' style='stroke-width: 0.75; stroke: #00BD7D;' />
+<circle cx='333.50' cy='177.91' r='6.53' style='stroke-width: 0.75; stroke: #009C95;' />
+<circle cx='322.81' cy='187.47' r='6.75' style='stroke-width: 0.75; stroke: #009297;' />
+<circle cx='296.09' cy='187.47' r='6.53' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='285.40' cy='225.69' r='6.06' style='stroke-width: 0.75; stroke: #3CC96C;' />
+<circle cx='296.09' cy='206.58' r='6.18' style='stroke-width: 0.75; stroke: #00AA8E;' />
+<circle cx='306.78' cy='235.24' r='6.53' style='stroke-width: 0.75; stroke: #008A98;' />
+<circle cx='290.75' cy='263.91' r='5.93' style='stroke-width: 0.75; stroke: #00AA8E;' />
+</g>
+</g>
+</svg>
diff --git a/inst/tinytest/_tinysnapshot/bubble_dual_discrete.svg b/inst/tinytest/_tinysnapshot/bubble_dual_discrete.svg
new file mode 100644
index 00000000..fd762626
--- /dev/null
+++ b/inst/tinytest/_tinysnapshot/bubble_dual_discrete.svg
@@ -0,0 +1,261 @@
+<?xml version='1.0' encoding='UTF-8' ?>
+<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='504.00pt' height='504.00pt' viewBox='0 0 504.00 504.00'>
+<g class='svglite'>
+<defs>
+  <style type='text/css'><![CDATA[
+    .svglite line, .svglite polyline, .svglite polygon, .svglite path, .svglite rect, .svglite circle {
+      fill: none;
+      stroke: #000000;
+      stroke-linecap: round;
+      stroke-linejoin: round;
+      stroke-miterlimit: 10.00;
+    }
+    .svglite text {
+      white-space: pre;
+    }
+    .svglite g.glyphgroup path {
+      fill: inherit;
+      stroke: none;
+    }
+  ]]></style>
+</defs>
+<rect width='100%' height='100%' style='stroke: none; fill: #FFFFFF;'/>
+<defs>
+  <clipPath id='cpMC4wMHw1MDQuMDB8MC4wMHw1MDQuMDA='>
+    <rect x='0.00' y='0.00' width='504.00' height='504.00' />
+  </clipPath>
+</defs>
+<g clip-path='url(#cpMC4wMHw1MDQuMDB8MC4wMHw1MDQuMDA=)'>
+</g>
+<defs>
+  <clipPath id='cpNTkuMDR8NTA0LjAwfDU5LjA0fDQzMC41Ng=='>
+    <rect x='59.04' y='59.04' width='444.96' height='371.52' />
+  </clipPath>
+</defs>
+<g clip-path='url(#cpNTkuMDR8NTA0LjAwfDU5LjA0fDQzMC41Ng==)'>
+</g>
+<g clip-path='url(#cpMC4wMHw1MDQuMDB8MC4wMHw1MDQuMDA=)'>
+<circle cx='430.57' cy='197.88' r='2.70' style='stroke-width: 0.75;' />
+<circle cx='430.57' cy='212.28' r='2.70' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='430.57' cy='226.68' r='2.70' style='stroke-width: 0.75; stroke: #61D04F;' />
+<text x='458.95' y='183.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='42.69px' lengthAdjust='spacingAndGlyphs'>Species</text>
+<text x='441.37' y='202.01' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='35.34px' lengthAdjust='spacingAndGlyphs'>setosa</text>
+<text x='441.37' y='216.41' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='51.36px' lengthAdjust='spacingAndGlyphs'>versicolor</text>
+<text x='441.37' y='230.81' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='44.03px' lengthAdjust='spacingAndGlyphs'>virginica</text>
+<circle cx='447.31' cy='277.32' r='1.35' style='stroke-width: 0.75;' />
+<circle cx='447.31' cy='291.72' r='3.76' style='stroke-width: 0.75;' />
+<circle cx='447.31' cy='306.12' r='4.77' style='stroke-width: 0.75;' />
+<circle cx='447.31' cy='320.52' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='447.31' cy='334.92' r='6.18' style='stroke-width: 0.75;' />
+<circle cx='447.31' cy='349.32' r='6.75' style='stroke-width: 0.75;' />
+<text x='458.35' y='262.92' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='83.36px' lengthAdjust='spacingAndGlyphs'>iris$Petal.Width</text>
+<text x='458.11' y='281.44' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>0.0</text>
+<text x='458.11' y='295.84' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>0.5</text>
+<text x='458.11' y='310.24' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>1.0</text>
+<text x='458.11' y='324.64' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>1.5</text>
+<text x='458.11' y='339.04' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>2.0</text>
+<text x='458.11' y='353.44' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>2.5</text>
+</g>
+<defs>
+  <clipPath id='cpMC4wMHwzOTkuNTd8MC4wMHw1MDQuMDA='>
+    <rect x='0.00' y='0.00' width='399.57' height='504.00' />
+  </clipPath>
+</defs>
+<g clip-path='url(#cpMC4wMHwzOTkuNTd8MC4wMHw1MDQuMDA=)'>
+<text x='229.30' y='485.28' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='67.36px' lengthAdjust='spacingAndGlyphs'>Petal.Length</text>
+<text transform='translate(12.96,244.80) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='70.70px' lengthAdjust='spacingAndGlyphs'>Sepal.Length</text>
+</g>
+<g clip-path='url(#cpMC4wMHw1MDQuMDB8MC4wMHw1MDQuMDA=)'>
+<line x1='71.65' y1='430.56' x2='392.30' y2='430.56' style='stroke-width: 0.75;' />
+<line x1='71.65' y1='430.56' x2='71.65' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='125.09' y1='430.56' x2='125.09' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='178.53' y1='430.56' x2='178.53' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='231.97' y1='430.56' x2='231.97' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='285.42' y1='430.56' x2='285.42' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='338.86' y1='430.56' x2='338.86' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='392.30' y1='430.56' x2='392.30' y2='437.76' style='stroke-width: 0.75;' />
+<text x='71.65' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>1</text>
+<text x='125.09' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>2</text>
+<text x='178.53' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>3</text>
+<text x='231.97' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>4</text>
+<text x='285.42' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>5</text>
+<text x='338.86' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>6</text>
+<text x='392.30' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>7</text>
+<line x1='59.04' y1='397.69' x2='59.04' y2='63.24' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='397.69' x2='51.84' y2='397.69' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='349.91' x2='51.84' y2='349.91' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='302.13' x2='51.84' y2='302.13' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='254.36' x2='51.84' y2='254.36' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='206.58' x2='51.84' y2='206.58' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='158.80' x2='51.84' y2='158.80' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='111.02' x2='51.84' y2='111.02' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='63.24' x2='51.84' y2='63.24' style='stroke-width: 0.75;' />
+<text transform='translate(41.76,397.69) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>4.5</text>
+<text transform='translate(41.76,349.91) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>5.0</text>
+<text transform='translate(41.76,302.13) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>5.5</text>
+<text transform='translate(41.76,254.36) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>6.0</text>
+<text transform='translate(41.76,206.58) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>6.5</text>
+<text transform='translate(41.76,158.80) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>7.0</text>
+<text transform='translate(41.76,111.02) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>7.5</text>
+<text transform='translate(41.76,63.24) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>8.0</text>
+<polygon points='59.04,430.56 399.57,430.56 399.57,59.04 59.04,59.04 ' style='stroke-width: 0.75;' />
+</g>
+<defs>
+  <clipPath id='cpNTkuMDR8Mzk5LjU3fDU5LjA0fDQzMC41Ng=='>
+    <rect x='59.04' y='59.04' width='340.53' height='371.52' />
+  </clipPath>
+</defs>
+<g clip-path='url(#cpNTkuMDR8Mzk5LjU3fDU5LjA0fDQzMC41Ng==)'>
+<circle cx='93.03' cy='340.36' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='93.03' cy='359.47' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='87.68' cy='378.58' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='98.37' cy='388.13' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='93.03' cy='349.91' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='109.06' cy='311.69' r='3.51' style='stroke-width: 0.75;' />
+<circle cx='93.03' cy='388.13' r='3.22' style='stroke-width: 0.75;' />
+<circle cx='98.37' cy='349.91' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='93.03' cy='407.24' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='98.37' cy='359.47' r='2.43' style='stroke-width: 0.75;' />
+<circle cx='98.37' cy='311.69' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='103.72' cy='369.02' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='93.03' cy='369.02' r='2.43' style='stroke-width: 0.75;' />
+<circle cx='77.00' cy='416.80' r='2.43' style='stroke-width: 0.75;' />
+<circle cx='82.34' cy='273.47' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='98.37' cy='283.02' r='3.51' style='stroke-width: 0.75;' />
+<circle cx='87.68' cy='311.69' r='3.51' style='stroke-width: 0.75;' />
+<circle cx='93.03' cy='340.36' r='3.22' style='stroke-width: 0.75;' />
+<circle cx='109.06' cy='283.02' r='3.22' style='stroke-width: 0.75;' />
+<circle cx='98.37' cy='340.36' r='3.22' style='stroke-width: 0.75;' />
+<circle cx='109.06' cy='311.69' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='98.37' cy='340.36' r='3.51' style='stroke-width: 0.75;' />
+<circle cx='71.65' cy='388.13' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='109.06' cy='340.36' r='3.76' style='stroke-width: 0.75;' />
+<circle cx='119.75' cy='369.02' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='103.72' cy='349.91' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='103.72' cy='349.91' r='3.51' style='stroke-width: 0.75;' />
+<circle cx='98.37' cy='330.80' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='93.03' cy='330.80' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='103.72' cy='378.58' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='103.72' cy='369.02' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='98.37' cy='311.69' r='3.51' style='stroke-width: 0.75;' />
+<circle cx='98.37' cy='330.80' r='2.43' style='stroke-width: 0.75;' />
+<circle cx='93.03' cy='302.13' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='98.37' cy='359.47' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='82.34' cy='349.91' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='87.68' cy='302.13' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='93.03' cy='359.47' r='2.43' style='stroke-width: 0.75;' />
+<circle cx='87.68' cy='407.24' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='98.37' cy='340.36' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='87.68' cy='349.91' r='3.22' style='stroke-width: 0.75;' />
+<circle cx='87.68' cy='397.69' r='3.22' style='stroke-width: 0.75;' />
+<circle cx='87.68' cy='407.24' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='103.72' cy='349.91' r='4.00' style='stroke-width: 0.75;' />
+<circle cx='119.75' cy='340.36' r='3.51' style='stroke-width: 0.75;' />
+<circle cx='93.03' cy='369.02' r='3.22' style='stroke-width: 0.75;' />
+<circle cx='103.72' cy='340.36' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='93.03' cy='388.13' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='98.37' cy='321.24' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='93.03' cy='349.91' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='269.38' cy='158.80' r='5.39' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='258.70' cy='216.13' r='5.53' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='280.07' cy='168.36' r='5.53' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='231.97' cy='302.13' r='5.24' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='264.04' cy='206.58' r='5.53' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='258.70' cy='283.02' r='5.24' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='269.38' cy='225.69' r='5.67' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='194.57' cy='359.47' r='4.77' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='264.04' cy='197.02' r='5.24' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='226.63' cy='330.80' r='5.39' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='205.25' cy='349.91' r='4.77' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='242.66' cy='263.91' r='5.53' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='231.97' cy='254.36' r='4.77' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='269.38' cy='244.80' r='5.39' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='210.60' cy='292.58' r='5.24' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='253.35' cy='187.47' r='5.39' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='258.70' cy='292.58' r='5.53' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='237.32' cy='273.47' r='4.77' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='258.70' cy='235.24' r='5.53' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='226.63' cy='292.58' r='4.93' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='274.73' cy='263.91' r='5.93' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='231.97' cy='244.80' r='5.24' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='280.07' cy='225.69' r='5.53' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='269.38' cy='244.80' r='5.09' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='248.01' cy='216.13' r='5.24' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='253.35' cy='197.02' r='5.39' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='274.73' cy='177.91' r='5.39' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='285.42' cy='187.47' r='5.80' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='258.70' cy='254.36' r='5.53' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='205.25' cy='283.02' r='4.77' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='221.29' cy='302.13' r='4.93' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='215.94' cy='302.13' r='4.77' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='226.63' cy='273.47' r='5.09' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='290.76' cy='254.36' r='5.67' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='258.70' cy='311.69' r='5.53' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='258.70' cy='254.36' r='5.67' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='269.38' cy='187.47' r='5.53' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='253.35' cy='225.69' r='5.24' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='237.32' cy='292.58' r='5.24' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='231.97' cy='302.13' r='5.24' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='253.35' cy='302.13' r='5.09' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='264.04' cy='244.80' r='5.39' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='231.97' cy='273.47' r='5.09' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='194.57' cy='349.91' r='4.77' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='242.66' cy='292.58' r='5.24' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='242.66' cy='283.02' r='5.09' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='242.66' cy='283.02' r='5.24' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='248.01' cy='235.24' r='5.24' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='178.53' cy='340.36' r='4.93' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='237.32' cy='283.02' r='5.24' style='stroke-width: 0.75; stroke: #DF536B;' />
+<circle cx='338.86' cy='225.69' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='290.76' cy='273.47' r='6.06' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='333.51' cy='149.24' r='6.30' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='317.48' cy='225.69' r='5.93' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='328.17' cy='206.58' r='6.42' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='370.92' cy='101.47' r='6.30' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='258.70' cy='359.47' r='5.80' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='354.89' cy='130.13' r='5.93' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='328.17' cy='187.47' r='5.93' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='344.20' cy='139.69' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='290.76' cy='206.58' r='6.18' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='301.45' cy='216.13' r='6.06' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='312.14' cy='177.91' r='6.30' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='285.42' cy='283.02' r='6.18' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='290.76' cy='273.47' r='6.64' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='301.45' cy='216.13' r='6.53' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='312.14' cy='206.58' r='5.93' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='376.27' cy='91.91' r='6.42' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='386.95' cy='91.91' r='6.53' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='285.42' cy='254.36' r='5.53' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='322.82' cy='168.36' r='6.53' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='280.07' cy='292.58' r='6.18' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='376.27' cy='91.91' r='6.18' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='280.07' cy='225.69' r='5.93' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='322.82' cy='187.47' r='6.30' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='338.86' cy='139.69' r='5.93' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='274.73' cy='235.24' r='5.93' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='280.07' cy='244.80' r='5.93' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='317.48' cy='216.13' r='6.30' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='328.17' cy='139.69' r='5.67' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='344.20' cy='120.58' r='6.06' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='360.23' cy='72.80' r='6.18' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='317.48' cy='216.13' r='6.42' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='290.76' cy='225.69' r='5.53' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='317.48' cy='244.80' r='5.39' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='344.20' cy='91.91' r='6.53' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='317.48' cy='225.69' r='6.64' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='312.14' cy='216.13' r='5.93' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='274.73' cy='254.36' r='5.93' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='306.79' cy='168.36' r='6.30' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='317.48' cy='187.47' r='6.64' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='290.76' cy='168.36' r='6.53' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='290.76' cy='273.47' r='6.06' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='333.51' cy='177.91' r='6.53' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='322.82' cy='187.47' r='6.75' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='296.10' cy='187.47' r='6.53' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='285.42' cy='225.69' r='6.06' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='296.10' cy='206.58' r='6.18' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='306.79' cy='235.24' r='6.53' style='stroke-width: 0.75; stroke: #61D04F;' />
+<circle cx='290.76' cy='263.91' r='5.93' style='stroke-width: 0.75; stroke: #61D04F;' />
+</g>
+</g>
+</svg>
diff --git a/inst/tinytest/_tinysnapshot/bubble_dual_fancy.svg b/inst/tinytest/_tinysnapshot/bubble_dual_fancy.svg
new file mode 100644
index 00000000..2bf07612
--- /dev/null
+++ b/inst/tinytest/_tinysnapshot/bubble_dual_fancy.svg
@@ -0,0 +1,266 @@
+<?xml version='1.0' encoding='UTF-8' ?>
+<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='504.00pt' height='504.00pt' viewBox='0 0 504.00 504.00'>
+<g class='svglite'>
+<defs>
+  <style type='text/css'><![CDATA[
+    .svglite line, .svglite polyline, .svglite polygon, .svglite path, .svglite rect, .svglite circle {
+      fill: none;
+      stroke: #000000;
+      stroke-linecap: round;
+      stroke-linejoin: round;
+      stroke-miterlimit: 10.00;
+    }
+    .svglite text {
+      white-space: pre;
+    }
+    .svglite g.glyphgroup path {
+      fill: inherit;
+      stroke: none;
+    }
+  ]]></style>
+</defs>
+<rect width='100%' height='100%' style='stroke: none; fill: #FFFFFF;'/>
+<defs>
+  <clipPath id='cpMC4wMHw1MDQuMDB8MC4wMHw1MDQuMDA='>
+    <rect x='0.00' y='0.00' width='504.00' height='504.00' />
+  </clipPath>
+</defs>
+<g clip-path='url(#cpMC4wMHw1MDQuMDB8MC4wMHw1MDQuMDA=)'>
+</g>
+<defs>
+  <clipPath id='cpNTcuNjB8NDczLjc2fDU5LjA0fDQzMC41Ng=='>
+    <rect x='57.60' y='59.04' width='416.16' height='371.52' />
+  </clipPath>
+</defs>
+<g clip-path='url(#cpNTcuNjB8NDczLjc2fDU5LjA0fDQzMC41Ng==)'>
+</g>
+<g clip-path='url(#cpMC4wMHw1MDQuMDB8MC4wMHw1MDQuMDA=)'>
+<image width='-18.00' height='86.40' x='54.25' y='103.02' preserveAspectRatio='none' xlink:href='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAABkCAYAAABHLFpgAAAAo0lEQVQYlXWLS1LDQAwFW62yA2GRKk7BQTgPx8+CzGhYYDtxPptX/V5LfH/9DCsDS3GkWCkOY6FKsWdgVywDW4pN8ZKBF8XfBM8pnhVbBuIWd/U/2Il8JWQvWCliV29ivNrG4xabyIHEE2FtH/Uo4glZCFexbmHhKQo/o/AUHT8sPEbhexS+2fEQHQ9RC803tNbZhjO1bNMmpmg4ca0dp1ju/gCmb0eaNdB1fgAAAABJRU5ErkJggg=='/>
+<text x='54.25' y='193.55' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='20.00px' lengthAdjust='spacingAndGlyphs'> 2.0</text>
+<text x='54.25' y='175.23' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='20.00px' lengthAdjust='spacingAndGlyphs'> 2.5</text>
+<text x='54.25' y='157.77' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='20.00px' lengthAdjust='spacingAndGlyphs'> 3.0</text>
+<text x='54.25' y='139.44' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='20.00px' lengthAdjust='spacingAndGlyphs'> 3.5</text>
+<text x='54.25' y='121.99' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='20.00px' lengthAdjust='spacingAndGlyphs'> 4.0</text>
+<text x='54.25' y='193.55' text-anchor='end' style='font-size: 12.00px;fill: #FFFFFF; font-family: "Liberation Sans";' textLength='17.98px' lengthAdjust='spacingAndGlyphs'>-   -</text>
+<text x='54.25' y='175.23' text-anchor='end' style='font-size: 12.00px;fill: #FFFFFF; font-family: "Liberation Sans";' textLength='17.98px' lengthAdjust='spacingAndGlyphs'>-   -</text>
+<text x='54.25' y='157.77' text-anchor='end' style='font-size: 12.00px;fill: #FFFFFF; font-family: "Liberation Sans";' textLength='17.98px' lengthAdjust='spacingAndGlyphs'>-   -</text>
+<text x='54.25' y='139.44' text-anchor='end' style='font-size: 12.00px;fill: #FFFFFF; font-family: "Liberation Sans";' textLength='17.98px' lengthAdjust='spacingAndGlyphs'>-   -</text>
+<text x='54.25' y='121.99' text-anchor='end' style='font-size: 12.00px;fill: #FFFFFF; font-family: "Liberation Sans";' textLength='17.98px' lengthAdjust='spacingAndGlyphs'>-   -</text>
+<text x='74.25' y='88.62' text-anchor='end' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='64.69px' lengthAdjust='spacingAndGlyphs'>Sepal.Width</text>
+<rect x='24.60' y='222.55' width='4.79' height='4.79' style='stroke-width: 0.75; fill: #000000; fill-opacity: 0.30;' />
+<rect x='19.25' y='233.72' width='15.49' height='15.49' style='stroke-width: 0.75; fill: #000000; fill-opacity: 0.30;' />
+<rect x='17.03' y='252.81' width='19.92' height='19.92' style='stroke-width: 0.75; fill: #000000; fill-opacity: 0.30;' />
+<rect x='15.33' y='277.13' width='23.32' height='23.32' style='stroke-width: 0.75; fill: #000000; fill-opacity: 0.30;' />
+<rect x='13.90' y='305.49' width='26.19' height='26.19' style='stroke-width: 0.75; fill: #000000; fill-opacity: 0.30;' />
+<rect x='12.63' y='337.26' width='28.71' height='28.71' style='stroke-width: 0.75; fill: #000000; fill-opacity: 0.30;' />
+<text x='45.59' y='210.54' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='83.36px' lengthAdjust='spacingAndGlyphs'>iris$Petal.Width</text>
+<text x='52.91' y='229.07' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>0.0</text>
+<text x='52.91' y='245.59' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>0.5</text>
+<text x='52.91' y='266.90' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>1.0</text>
+<text x='52.91' y='292.92' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>1.5</text>
+<text x='52.91' y='322.71' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>2.0</text>
+<text x='52.91' y='355.75' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>2.5</text>
+</g>
+<defs>
+  <clipPath id='cpMTA0LjM3fDUwNC4wMHwwLjAwfDUwNC4wMA=='>
+    <rect x='104.37' y='0.00' width='399.63' height='504.00' />
+  </clipPath>
+</defs>
+<g clip-path='url(#cpMTA0LjM3fDUwNC4wMHwwLjAwfDUwNC4wMA==)'>
+<text x='317.86' y='485.28' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='67.36px' lengthAdjust='spacingAndGlyphs'>Petal.Length</text>
+<text transform='translate(115.89,244.80) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='70.70px' lengthAdjust='spacingAndGlyphs'>Sepal.Length</text>
+</g>
+<g clip-path='url(#cpMC4wMHw1MDQuMDB8MC4wMHw1MDQuMDA=)'>
+<line x1='173.51' y1='430.56' x2='467.11' y2='430.56' style='stroke-width: 0.75;' />
+<line x1='173.51' y1='430.56' x2='173.51' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='222.45' y1='430.56' x2='222.45' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='271.38' y1='430.56' x2='271.38' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='320.31' y1='430.56' x2='320.31' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='369.24' y1='430.56' x2='369.24' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='418.17' y1='430.56' x2='418.17' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='467.11' y1='430.56' x2='467.11' y2='437.76' style='stroke-width: 0.75;' />
+<text x='173.51' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>1</text>
+<text x='222.45' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>2</text>
+<text x='271.38' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>3</text>
+<text x='320.31' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>4</text>
+<text x='369.24' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>5</text>
+<text x='418.17' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>6</text>
+<text x='467.11' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>7</text>
+<line x1='161.97' y1='397.69' x2='161.97' y2='63.24' style='stroke-width: 0.75;' />
+<line x1='161.97' y1='397.69' x2='154.77' y2='397.69' style='stroke-width: 0.75;' />
+<line x1='161.97' y1='349.91' x2='154.77' y2='349.91' style='stroke-width: 0.75;' />
+<line x1='161.97' y1='302.13' x2='154.77' y2='302.13' style='stroke-width: 0.75;' />
+<line x1='161.97' y1='254.36' x2='154.77' y2='254.36' style='stroke-width: 0.75;' />
+<line x1='161.97' y1='206.58' x2='154.77' y2='206.58' style='stroke-width: 0.75;' />
+<line x1='161.97' y1='158.80' x2='154.77' y2='158.80' style='stroke-width: 0.75;' />
+<line x1='161.97' y1='111.02' x2='154.77' y2='111.02' style='stroke-width: 0.75;' />
+<line x1='161.97' y1='63.24' x2='154.77' y2='63.24' style='stroke-width: 0.75;' />
+<text transform='translate(144.69,397.69) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>4.5</text>
+<text transform='translate(144.69,349.91) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>5.0</text>
+<text transform='translate(144.69,302.13) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>5.5</text>
+<text transform='translate(144.69,254.36) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>6.0</text>
+<text transform='translate(144.69,206.58) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>6.5</text>
+<text transform='translate(144.69,158.80) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>7.0</text>
+<text transform='translate(144.69,111.02) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>7.5</text>
+<text transform='translate(144.69,63.24) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>8.0</text>
+<polygon points='161.97,430.56 473.76,430.56 473.76,59.04 161.97,59.04 ' style='stroke-width: 0.75;' />
+</g>
+<defs>
+  <clipPath id='cpMTYxLjk3fDQ3My43Nnw1OS4wNHw0MzAuNTY='>
+    <rect x='161.97' y='59.04' width='311.79' height='371.52' />
+  </clipPath>
+</defs>
+<g clip-path='url(#cpMTYxLjk3fDQ3My43Nnw1OS4wNHw0MzAuNTY=)'>
+<rect x='187.31' y='334.58' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #008297; fill: #008297; fill-opacity: 0.30;' />
+<rect x='187.31' y='353.69' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='182.42' y='372.80' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #009C95; fill: #009C95; fill-opacity: 0.30;' />
+<rect x='192.20' y='382.36' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #00A491; fill: #00A491; fill-opacity: 0.30;' />
+<rect x='187.31' y='344.13' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #007996; fill: #007996; fill-opacity: 0.30;' />
+<rect x='200.59' y='304.51' width='14.36' height='14.36' style='stroke-width: 0.75; stroke: #005E8D; fill: #005E8D; fill-opacity: 0.30;' />
+<rect x='186.55' y='381.60' width='13.07' height='13.07' style='stroke-width: 0.75; stroke: #008A98; fill: #008A98; fill-opacity: 0.30;' />
+<rect x='192.20' y='344.13' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #008A98; fill: #008A98; fill-opacity: 0.30;' />
+<rect x='187.31' y='401.47' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #00B189; fill: #00B189; fill-opacity: 0.30;' />
+<rect x='193.19' y='354.68' width='9.57' height='9.57' style='stroke-width: 0.75; stroke: #00A491; fill: #00A491; fill-opacity: 0.30;' />
+<rect x='192.20' y='305.91' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #007094; fill: #007094; fill-opacity: 0.30;' />
+<rect x='197.10' y='363.25' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #008A98; fill: #008A98; fill-opacity: 0.30;' />
+<rect x='188.30' y='364.24' width='9.57' height='9.57' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='173.62' y='412.01' width='9.57' height='9.57' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='177.52' y='267.69' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #045489; fill: #045489; fill-opacity: 0.30;' />
+<rect x='190.80' y='275.84' width='14.36' height='14.36' style='stroke-width: 0.75; stroke: #44286E; fill: #44286E; fill-opacity: 0.30;' />
+<rect x='181.01' y='304.51' width='14.36' height='14.36' style='stroke-width: 0.75; stroke: #005E8D; fill: #005E8D; fill-opacity: 0.30;' />
+<rect x='186.55' y='333.82' width='13.07' height='13.07' style='stroke-width: 0.75; stroke: #008297; fill: #008297; fill-opacity: 0.30;' />
+<rect x='201.23' y='276.48' width='13.07' height='13.07' style='stroke-width: 0.75; stroke: #006690; fill: #006690; fill-opacity: 0.30;' />
+<rect x='191.44' y='333.82' width='13.07' height='13.07' style='stroke-width: 0.75; stroke: #006690; fill: #006690; fill-opacity: 0.30;' />
+<rect x='201.99' y='305.91' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #008A98; fill: #008A98; fill-opacity: 0.30;' />
+<rect x='190.80' y='333.18' width='14.36' height='14.36' style='stroke-width: 0.75; stroke: #007094; fill: #007094; fill-opacity: 0.30;' />
+<rect x='167.74' y='382.36' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #007996; fill: #007996; fill-opacity: 0.30;' />
+<rect x='200.02' y='332.61' width='15.49' height='15.49' style='stroke-width: 0.75; stroke: #009297; fill: #009297; fill-opacity: 0.30;' />
+<rect x='211.78' y='363.25' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #008A98; fill: #008A98; fill-opacity: 0.30;' />
+<rect x='197.10' y='344.13' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='195.69' y='342.73' width='14.36' height='14.36' style='stroke-width: 0.75; stroke: #008A98; fill: #008A98; fill-opacity: 0.30;' />
+<rect x='192.20' y='325.02' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #008297; fill: #008297; fill-opacity: 0.30;' />
+<rect x='187.31' y='325.02' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #008A98; fill: #008A98; fill-opacity: 0.30;' />
+<rect x='197.10' y='372.80' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #009C95; fill: #009C95; fill-opacity: 0.30;' />
+<rect x='197.10' y='363.25' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #00A491; fill: #00A491; fill-opacity: 0.30;' />
+<rect x='190.80' y='304.51' width='14.36' height='14.36' style='stroke-width: 0.75; stroke: #008A98; fill: #008A98; fill-opacity: 0.30;' />
+<rect x='193.19' y='326.01' width='9.57' height='9.57' style='stroke-width: 0.75; stroke: #284782; fill: #284782; fill-opacity: 0.30;' />
+<rect x='187.31' y='296.36' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #353E7B; fill: #353E7B; fill-opacity: 0.30;' />
+<rect x='192.20' y='353.69' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #00A491; fill: #00A491; fill-opacity: 0.30;' />
+<rect x='177.52' y='344.13' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #009C95; fill: #009C95; fill-opacity: 0.30;' />
+<rect x='182.42' y='296.36' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #008297; fill: #008297; fill-opacity: 0.30;' />
+<rect x='188.30' y='354.68' width='9.57' height='9.57' style='stroke-width: 0.75; stroke: #007996; fill: #007996; fill-opacity: 0.30;' />
+<rect x='182.42' y='401.47' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='192.20' y='334.58' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #008A98; fill: #008A98; fill-opacity: 0.30;' />
+<rect x='181.66' y='343.37' width='13.07' height='13.07' style='stroke-width: 0.75; stroke: #008297; fill: #008297; fill-opacity: 0.30;' />
+<rect x='181.66' y='391.15' width='13.07' height='13.07' style='stroke-width: 0.75; stroke: #7FD355; fill: #7FD355; fill-opacity: 0.30;' />
+<rect x='182.42' y='401.47' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #009C95; fill: #009C95; fill-opacity: 0.30;' />
+<rect x='194.62' y='341.66' width='16.51' height='16.51' style='stroke-width: 0.75; stroke: #008297; fill: #008297; fill-opacity: 0.30;' />
+<rect x='210.37' y='333.18' width='14.36' height='14.36' style='stroke-width: 0.75; stroke: #006690; fill: #006690; fill-opacity: 0.30;' />
+<rect x='186.55' y='362.48' width='13.07' height='13.07' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='197.10' y='334.58' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #006690; fill: #006690; fill-opacity: 0.30;' />
+<rect x='187.31' y='382.36' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #009C95; fill: #009C95; fill-opacity: 0.30;' />
+<rect x='192.20' y='315.47' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #007094; fill: #007094; fill-opacity: 0.30;' />
+<rect x='187.31' y='344.13' width='11.55' height='11.55' style='stroke-width: 0.75; stroke: #009297; fill: #009297; fill-opacity: 0.30;' />
+<rect x='343.22' y='147.45' width='22.69' height='22.69' style='stroke-width: 0.75; stroke: #009C95; fill: #009C95; fill-opacity: 0.30;' />
+<rect x='333.12' y='204.47' width='23.32' height='23.32' style='stroke-width: 0.75; stroke: #009C95; fill: #009C95; fill-opacity: 0.30;' />
+<rect x='352.69' y='156.70' width='23.32' height='23.32' style='stroke-width: 0.75; stroke: #00A491; fill: #00A491; fill-opacity: 0.30;' />
+<rect x='309.29' y='291.11' width='22.04' height='22.04' style='stroke-width: 0.75; stroke: #7FD355; fill: #7FD355; fill-opacity: 0.30;' />
+<rect x='338.01' y='194.92' width='23.32' height='23.32' style='stroke-width: 0.75; stroke: #00B883; fill: #00B883; fill-opacity: 0.30;' />
+<rect x='333.75' y='272.00' width='22.04' height='22.04' style='stroke-width: 0.75; stroke: #00B883; fill: #00B883; fill-opacity: 0.30;' />
+<rect x='342.60' y='213.72' width='23.93' height='23.93' style='stroke-width: 0.75; stroke: #009297; fill: #009297; fill-opacity: 0.30;' />
+<rect x='276.10' y='349.51' width='19.92' height='19.92' style='stroke-width: 0.75; stroke: #65CF60; fill: #65CF60; fill-opacity: 0.30;' />
+<rect x='338.65' y='186.00' width='22.04' height='22.04' style='stroke-width: 0.75; stroke: #00B189; fill: #00B189; fill-opacity: 0.30;' />
+<rect x='304.07' y='319.45' width='22.69' height='22.69' style='stroke-width: 0.75; stroke: #00BD7D; fill: #00BD7D; fill-opacity: 0.30;' />
+<rect x='285.88' y='339.95' width='19.92' height='19.92' style='stroke-width: 0.75; stroke: #C2DE34; fill: #C2DE34; fill-opacity: 0.30;' />
+<rect x='318.44' y='252.25' width='23.32' height='23.32' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='310.35' y='244.40' width='19.92' height='19.92' style='stroke-width: 0.75; stroke: #97D74A; fill: #97D74A; fill-opacity: 0.30;' />
+<rect x='343.22' y='233.45' width='22.69' height='22.69' style='stroke-width: 0.75; stroke: #00B189; fill: #00B189; fill-opacity: 0.30;' />
+<rect x='289.72' y='281.56' width='22.04' height='22.04' style='stroke-width: 0.75; stroke: #00B189; fill: #00B189; fill-opacity: 0.30;' />
+<rect x='328.54' y='176.12' width='22.69' height='22.69' style='stroke-width: 0.75; stroke: #00A491; fill: #00A491; fill-opacity: 0.30;' />
+<rect x='333.12' y='280.92' width='23.32' height='23.32' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='315.24' y='263.51' width='19.92' height='19.92' style='stroke-width: 0.75; stroke: #00BD7D; fill: #00BD7D; fill-opacity: 0.30;' />
+<rect x='333.12' y='223.58' width='23.32' height='23.32' style='stroke-width: 0.75; stroke: #97D74A; fill: #97D74A; fill-opacity: 0.30;' />
+<rect x='305.09' y='282.25' width='20.66' height='20.66' style='stroke-width: 0.75; stroke: #3CC96C; fill: #3CC96C; fill-opacity: 0.30;' />
+<rect x='346.91' y='251.37' width='25.09' height='25.09' style='stroke-width: 0.75; stroke: #009C95; fill: #009C95; fill-opacity: 0.30;' />
+<rect x='309.29' y='233.78' width='22.04' height='22.04' style='stroke-width: 0.75; stroke: #00B883; fill: #00B883; fill-opacity: 0.30;' />
+<rect x='352.69' y='214.03' width='23.32' height='23.32' style='stroke-width: 0.75; stroke: #3CC96C; fill: #3CC96C; fill-opacity: 0.30;' />
+<rect x='343.88' y='234.12' width='21.36' height='21.36' style='stroke-width: 0.75; stroke: #00B883; fill: #00B883; fill-opacity: 0.30;' />
+<rect x='323.97' y='205.11' width='22.04' height='22.04' style='stroke-width: 0.75; stroke: #00B189; fill: #00B189; fill-opacity: 0.30;' />
+<rect x='328.54' y='185.68' width='22.69' height='22.69' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='348.11' y='166.57' width='22.69' height='22.69' style='stroke-width: 0.75; stroke: #00B883; fill: #00B883; fill-opacity: 0.30;' />
+<rect x='356.98' y='175.21' width='24.52' height='24.52' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='333.12' y='242.70' width='23.32' height='23.32' style='stroke-width: 0.75; stroke: #00B189; fill: #00B189; fill-opacity: 0.30;' />
+<rect x='285.88' y='273.06' width='19.92' height='19.92' style='stroke-width: 0.75; stroke: #01C475; fill: #01C475; fill-opacity: 0.30;' />
+<rect x='300.19' y='291.80' width='20.66' height='20.66' style='stroke-width: 0.75; stroke: #65CF60; fill: #65CF60; fill-opacity: 0.30;' />
+<rect x='295.67' y='292.17' width='19.92' height='19.92' style='stroke-width: 0.75; stroke: #65CF60; fill: #65CF60; fill-opacity: 0.30;' />
+<rect x='304.73' y='262.78' width='21.36' height='21.36' style='stroke-width: 0.75; stroke: #00BD7D; fill: #00BD7D; fill-opacity: 0.30;' />
+<rect x='362.17' y='242.39' width='23.93' height='23.93' style='stroke-width: 0.75; stroke: #00BD7D; fill: #00BD7D; fill-opacity: 0.30;' />
+<rect x='333.12' y='300.03' width='23.32' height='23.32' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='332.81' y='242.39' width='23.93' height='23.93' style='stroke-width: 0.75; stroke: #008A98; fill: #008A98; fill-opacity: 0.30;' />
+<rect x='342.90' y='175.81' width='23.32' height='23.32' style='stroke-width: 0.75; stroke: #00A491; fill: #00A491; fill-opacity: 0.30;' />
+<rect x='328.86' y='214.67' width='22.04' height='22.04' style='stroke-width: 0.75; stroke: #7FD355; fill: #7FD355; fill-opacity: 0.30;' />
+<rect x='314.18' y='281.56' width='22.04' height='22.04' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='309.29' y='291.11' width='22.04' height='22.04' style='stroke-width: 0.75; stroke: #3CC96C; fill: #3CC96C; fill-opacity: 0.30;' />
+<rect x='329.20' y='291.45' width='21.36' height='21.36' style='stroke-width: 0.75; stroke: #01C475; fill: #01C475; fill-opacity: 0.30;' />
+<rect x='338.32' y='233.45' width='22.69' height='22.69' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='309.63' y='262.78' width='21.36' height='21.36' style='stroke-width: 0.75; stroke: #01C475; fill: #01C475; fill-opacity: 0.30;' />
+<rect x='276.10' y='339.95' width='19.92' height='19.92' style='stroke-width: 0.75; stroke: #7FD355; fill: #7FD355; fill-opacity: 0.30;' />
+<rect x='319.08' y='281.56' width='22.04' height='22.04' style='stroke-width: 0.75; stroke: #00BD7D; fill: #00BD7D; fill-opacity: 0.30;' />
+<rect x='319.41' y='272.34' width='21.36' height='21.36' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='319.08' y='272.00' width='22.04' height='22.04' style='stroke-width: 0.75; stroke: #00B189; fill: #00B189; fill-opacity: 0.30;' />
+<rect x='323.97' y='224.22' width='22.04' height='22.04' style='stroke-width: 0.75; stroke: #00B189; fill: #00B189; fill-opacity: 0.30;' />
+<rect x='261.05' y='330.03' width='20.66' height='20.66' style='stroke-width: 0.75; stroke: #3CC96C; fill: #3CC96C; fill-opacity: 0.30;' />
+<rect x='314.18' y='272.00' width='22.04' height='22.04' style='stroke-width: 0.75; stroke: #00B883; fill: #00B883; fill-opacity: 0.30;' />
+<rect x='403.82' y='211.33' width='28.71' height='28.71' style='stroke-width: 0.75; stroke: #009297; fill: #009297; fill-opacity: 0.30;' />
+<rect x='361.31' y='260.64' width='25.65' height='25.65' style='stroke-width: 0.75; stroke: #00BD7D; fill: #00BD7D; fill-opacity: 0.30;' />
+<rect x='399.92' y='135.89' width='26.72' height='26.72' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='386.06' y='213.14' width='25.09' height='25.09' style='stroke-width: 0.75; stroke: #00B189; fill: #00B189; fill-opacity: 0.30;' />
+<rect x='394.77' y='192.96' width='27.23' height='27.23' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='434.17' y='88.11' width='26.72' height='26.72' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='332.52' y='347.21' width='24.52' height='24.52' style='stroke-width: 0.75; stroke: #3CC96C; fill: #3CC96C; fill-opacity: 0.30;' />
+<rect x='420.31' y='117.59' width='25.09' height='25.09' style='stroke-width: 0.75; stroke: #00B189; fill: #00B189; fill-opacity: 0.30;' />
+<rect x='395.84' y='174.92' width='25.09' height='25.09' style='stroke-width: 0.75; stroke: #3CC96C; fill: #3CC96C; fill-opacity: 0.30;' />
+<rect x='408.71' y='125.33' width='28.71' height='28.71' style='stroke-width: 0.75; stroke: #007996; fill: #007996; fill-opacity: 0.30;' />
+<rect x='361.04' y='193.48' width='26.19' height='26.19' style='stroke-width: 0.75; stroke: #009C95; fill: #009C95; fill-opacity: 0.30;' />
+<rect x='371.10' y='203.31' width='25.65' height='25.65' style='stroke-width: 0.75; stroke: #00BD7D; fill: #00BD7D; fill-opacity: 0.30;' />
+<rect x='380.35' y='164.55' width='26.72' height='26.72' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='356.15' y='269.93' width='26.19' height='26.19' style='stroke-width: 0.75; stroke: #3CC96C; fill: #3CC96C; fill-opacity: 0.30;' />
+<rect x='360.02' y='259.35' width='28.23' height='28.23' style='stroke-width: 0.75; stroke: #00B883; fill: #00B883; fill-opacity: 0.30;' />
+<rect x='370.05' y='202.26' width='27.74' height='27.74' style='stroke-width: 0.75; stroke: #009C95; fill: #009C95; fill-opacity: 0.30;' />
+<rect x='381.16' y='194.03' width='25.09' height='25.09' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='438.81' y='78.30' width='27.23' height='27.23' style='stroke-width: 0.75; stroke: #006690; fill: #006690; fill-opacity: 0.30;' />
+<rect x='448.34' y='78.04' width='27.74' height='27.74' style='stroke-width: 0.75; stroke: #01C475; fill: #01C475; fill-opacity: 0.30;' />
+<rect x='357.58' y='242.70' width='23.32' height='23.32' style='stroke-width: 0.75; stroke: #97D74A; fill: #97D74A; fill-opacity: 0.30;' />
+<rect x='389.63' y='154.49' width='27.74' height='27.74' style='stroke-width: 0.75; stroke: #009C95; fill: #009C95; fill-opacity: 0.30;' />
+<rect x='351.25' y='279.48' width='26.19' height='26.19' style='stroke-width: 0.75; stroke: #00B883; fill: #00B883; fill-opacity: 0.30;' />
+<rect x='439.33' y='78.82' width='26.19' height='26.19' style='stroke-width: 0.75; stroke: #00B883; fill: #00B883; fill-opacity: 0.30;' />
+<rect x='351.80' y='213.14' width='25.09' height='25.09' style='stroke-width: 0.75; stroke: #00BD7D; fill: #00BD7D; fill-opacity: 0.30;' />
+<rect x='390.14' y='174.11' width='26.72' height='26.72' style='stroke-width: 0.75; stroke: #009297; fill: #009297; fill-opacity: 0.30;' />
+<rect x='405.63' y='127.14' width='25.09' height='25.09' style='stroke-width: 0.75; stroke: #009C95; fill: #009C95; fill-opacity: 0.30;' />
+<rect x='346.91' y='222.70' width='25.09' height='25.09' style='stroke-width: 0.75; stroke: #00B883; fill: #00B883; fill-opacity: 0.30;' />
+<rect x='351.80' y='232.26' width='25.09' height='25.09' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='385.24' y='202.78' width='26.72' height='26.72' style='stroke-width: 0.75; stroke: #00B883; fill: #00B883; fill-opacity: 0.30;' />
+<rect x='396.42' y='127.72' width='23.93' height='23.93' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='410.24' y='107.75' width='25.65' height='25.65' style='stroke-width: 0.75; stroke: #00B883; fill: #00B883; fill-opacity: 0.30;' />
+<rect x='424.65' y='59.71' width='26.19' height='26.19' style='stroke-width: 0.75; stroke: #006690; fill: #006690; fill-opacity: 0.30;' />
+<rect x='384.98' y='202.52' width='27.23' height='27.23' style='stroke-width: 0.75; stroke: #00B883; fill: #00B883; fill-opacity: 0.30;' />
+<rect x='362.47' y='214.03' width='23.32' height='23.32' style='stroke-width: 0.75; stroke: #00B883; fill: #00B883; fill-opacity: 0.30;' />
+<rect x='387.25' y='233.45' width='22.69' height='22.69' style='stroke-width: 0.75; stroke: #01C475; fill: #01C475; fill-opacity: 0.30;' />
+<rect x='409.20' y='78.04' width='27.74' height='27.74' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='384.49' y='211.57' width='28.23' height='28.23' style='stroke-width: 0.75; stroke: #008A98; fill: #008A98; fill-opacity: 0.30;' />
+<rect x='381.16' y='203.59' width='25.09' height='25.09' style='stroke-width: 0.75; stroke: #00A491; fill: #00A491; fill-opacity: 0.30;' />
+<rect x='346.91' y='241.81' width='25.09' height='25.09' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='375.46' y='155.00' width='26.72' height='26.72' style='stroke-width: 0.75; stroke: #00A491; fill: #00A491; fill-opacity: 0.30;' />
+<rect x='384.49' y='173.35' width='28.23' height='28.23' style='stroke-width: 0.75; stroke: #00A491; fill: #00A491; fill-opacity: 0.30;' />
+<rect x='360.27' y='154.49' width='27.74' height='27.74' style='stroke-width: 0.75; stroke: #00A491; fill: #00A491; fill-opacity: 0.30;' />
+<rect x='361.31' y='260.64' width='25.65' height='25.65' style='stroke-width: 0.75; stroke: #00BD7D; fill: #00BD7D; fill-opacity: 0.30;' />
+<rect x='399.41' y='164.04' width='27.74' height='27.74' style='stroke-width: 0.75; stroke: #009C95; fill: #009C95; fill-opacity: 0.30;' />
+<rect x='389.14' y='173.11' width='28.71' height='28.71' style='stroke-width: 0.75; stroke: #009297; fill: #009297; fill-opacity: 0.30;' />
+<rect x='365.16' y='173.60' width='27.74' height='27.74' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='356.42' y='212.87' width='25.65' height='25.65' style='stroke-width: 0.75; stroke: #3CC96C; fill: #3CC96C; fill-opacity: 0.30;' />
+<rect x='365.93' y='193.48' width='26.19' height='26.19' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+<rect x='374.95' y='221.38' width='27.74' height='27.74' style='stroke-width: 0.75; stroke: #008A98; fill: #008A98; fill-opacity: 0.30;' />
+<rect x='361.59' y='251.37' width='25.09' height='25.09' style='stroke-width: 0.75; stroke: #00AA8E; fill: #00AA8E; fill-opacity: 0.30;' />
+</g>
+</g>
+</svg>
diff --git a/inst/tinytest/_tinysnapshot/bubble_simple.svg b/inst/tinytest/_tinysnapshot/bubble_simple.svg
new file mode 100644
index 00000000..b0ea4936
--- /dev/null
+++ b/inst/tinytest/_tinysnapshot/bubble_simple.svg
@@ -0,0 +1,245 @@
+<?xml version='1.0' encoding='UTF-8' ?>
+<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='504.00pt' height='504.00pt' viewBox='0 0 504.00 504.00'>
+<g class='svglite'>
+<defs>
+  <style type='text/css'><![CDATA[
+    .svglite line, .svglite polyline, .svglite polygon, .svglite path, .svglite rect, .svglite circle {
+      fill: none;
+      stroke: #000000;
+      stroke-linecap: round;
+      stroke-linejoin: round;
+      stroke-miterlimit: 10.00;
+    }
+    .svglite text {
+      white-space: pre;
+    }
+    .svglite g.glyphgroup path {
+      fill: inherit;
+      stroke: none;
+    }
+  ]]></style>
+</defs>
+<rect width='100%' height='100%' style='stroke: none; fill: #FFFFFF;'/>
+<defs>
+  <clipPath id='cpMC4wMHw1MDQuMDB8MC4wMHw1MDQuMDA='>
+    <rect x='0.00' y='0.00' width='504.00' height='504.00' />
+  </clipPath>
+</defs>
+<g clip-path='url(#cpMC4wMHw1MDQuMDB8MC4wMHw1MDQuMDA=)'>
+<circle cx='447.14' cy='216.00' r='1.35' style='stroke-width: 0.75;' />
+<circle cx='447.14' cy='230.40' r='3.76' style='stroke-width: 0.75;' />
+<circle cx='447.14' cy='244.80' r='4.77' style='stroke-width: 0.75;' />
+<circle cx='447.14' cy='259.20' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='447.14' cy='273.60' r='6.18' style='stroke-width: 0.75;' />
+<circle cx='447.14' cy='288.00' r='6.75' style='stroke-width: 0.75;' />
+<text x='458.18' y='201.60' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='83.36px' lengthAdjust='spacingAndGlyphs'>iris$Petal.Width</text>
+<text x='457.94' y='220.13' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>0.0</text>
+<text x='457.94' y='234.53' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>0.5</text>
+<text x='457.94' y='248.93' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>1.0</text>
+<text x='457.94' y='263.33' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>1.5</text>
+<text x='457.94' y='277.73' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>2.0</text>
+<text x='457.94' y='292.13' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>2.5</text>
+</g>
+<defs>
+  <clipPath id='cpMC4wMHwzOTkuNDB8MC4wMHw1MDQuMDA='>
+    <rect x='0.00' y='0.00' width='399.40' height='504.00' />
+  </clipPath>
+</defs>
+<g clip-path='url(#cpMC4wMHwzOTkuNDB8MC4wMHw1MDQuMDA=)'>
+<text x='229.22' y='485.28' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='67.36px' lengthAdjust='spacingAndGlyphs'>Petal.Length</text>
+<text transform='translate(12.96,244.80) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='70.70px' lengthAdjust='spacingAndGlyphs'>Sepal.Length</text>
+</g>
+<g clip-path='url(#cpMC4wMHw1MDQuMDB8MC4wMHw1MDQuMDA=)'>
+<line x1='71.65' y1='430.56' x2='392.14' y2='430.56' style='stroke-width: 0.75;' />
+<line x1='71.65' y1='430.56' x2='71.65' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='125.06' y1='430.56' x2='125.06' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='178.48' y1='430.56' x2='178.48' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='231.89' y1='430.56' x2='231.89' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='285.31' y1='430.56' x2='285.31' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='338.72' y1='430.56' x2='338.72' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='392.14' y1='430.56' x2='392.14' y2='437.76' style='stroke-width: 0.75;' />
+<text x='71.65' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>1</text>
+<text x='125.06' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>2</text>
+<text x='178.48' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>3</text>
+<text x='231.89' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>4</text>
+<text x='285.31' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>5</text>
+<text x='338.72' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>6</text>
+<text x='392.14' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>7</text>
+<line x1='59.04' y1='397.69' x2='59.04' y2='63.24' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='397.69' x2='51.84' y2='397.69' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='349.91' x2='51.84' y2='349.91' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='302.13' x2='51.84' y2='302.13' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='254.36' x2='51.84' y2='254.36' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='206.58' x2='51.84' y2='206.58' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='158.80' x2='51.84' y2='158.80' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='111.02' x2='51.84' y2='111.02' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='63.24' x2='51.84' y2='63.24' style='stroke-width: 0.75;' />
+<text transform='translate(41.76,397.69) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>4.5</text>
+<text transform='translate(41.76,349.91) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>5.0</text>
+<text transform='translate(41.76,302.13) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>5.5</text>
+<text transform='translate(41.76,254.36) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>6.0</text>
+<text transform='translate(41.76,206.58) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>6.5</text>
+<text transform='translate(41.76,158.80) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>7.0</text>
+<text transform='translate(41.76,111.02) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>7.5</text>
+<text transform='translate(41.76,63.24) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>8.0</text>
+<polygon points='59.04,430.56 399.40,430.56 399.40,59.04 59.04,59.04 ' style='stroke-width: 0.75;' />
+</g>
+<defs>
+  <clipPath id='cpNTkuMDR8Mzk5LjQwfDU5LjA0fDQzMC41Ng=='>
+    <rect x='59.04' y='59.04' width='340.36' height='371.52' />
+  </clipPath>
+</defs>
+<g clip-path='url(#cpNTkuMDR8Mzk5LjQwfDU5LjA0fDQzMC41Ng==)'>
+<circle cx='93.01' cy='340.36' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='93.01' cy='359.47' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='87.67' cy='378.58' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='98.35' cy='388.13' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='93.01' cy='349.91' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='109.04' cy='311.69' r='3.51' style='stroke-width: 0.75;' />
+<circle cx='93.01' cy='388.13' r='3.22' style='stroke-width: 0.75;' />
+<circle cx='98.35' cy='349.91' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='93.01' cy='407.24' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='98.35' cy='359.47' r='2.43' style='stroke-width: 0.75;' />
+<circle cx='98.35' cy='311.69' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='103.69' cy='369.02' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='93.01' cy='369.02' r='2.43' style='stroke-width: 0.75;' />
+<circle cx='76.99' cy='416.80' r='2.43' style='stroke-width: 0.75;' />
+<circle cx='82.33' cy='273.47' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='98.35' cy='283.02' r='3.51' style='stroke-width: 0.75;' />
+<circle cx='87.67' cy='311.69' r='3.51' style='stroke-width: 0.75;' />
+<circle cx='93.01' cy='340.36' r='3.22' style='stroke-width: 0.75;' />
+<circle cx='109.04' cy='283.02' r='3.22' style='stroke-width: 0.75;' />
+<circle cx='98.35' cy='340.36' r='3.22' style='stroke-width: 0.75;' />
+<circle cx='109.04' cy='311.69' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='98.35' cy='340.36' r='3.51' style='stroke-width: 0.75;' />
+<circle cx='71.65' cy='388.13' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='109.04' cy='340.36' r='3.76' style='stroke-width: 0.75;' />
+<circle cx='119.72' cy='369.02' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='103.69' cy='349.91' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='103.69' cy='349.91' r='3.51' style='stroke-width: 0.75;' />
+<circle cx='98.35' cy='330.80' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='93.01' cy='330.80' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='103.69' cy='378.58' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='103.69' cy='369.02' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='98.35' cy='311.69' r='3.51' style='stroke-width: 0.75;' />
+<circle cx='98.35' cy='330.80' r='2.43' style='stroke-width: 0.75;' />
+<circle cx='93.01' cy='302.13' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='98.35' cy='359.47' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='82.33' cy='349.91' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='87.67' cy='302.13' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='93.01' cy='359.47' r='2.43' style='stroke-width: 0.75;' />
+<circle cx='87.67' cy='407.24' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='98.35' cy='340.36' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='87.67' cy='349.91' r='3.22' style='stroke-width: 0.75;' />
+<circle cx='87.67' cy='397.69' r='3.22' style='stroke-width: 0.75;' />
+<circle cx='87.67' cy='407.24' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='103.69' cy='349.91' r='4.00' style='stroke-width: 0.75;' />
+<circle cx='119.72' cy='340.36' r='3.51' style='stroke-width: 0.75;' />
+<circle cx='93.01' cy='369.02' r='3.22' style='stroke-width: 0.75;' />
+<circle cx='103.69' cy='340.36' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='93.01' cy='388.13' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='98.35' cy='321.24' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='93.01' cy='349.91' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='269.28' cy='158.80' r='5.39' style='stroke-width: 0.75;' />
+<circle cx='258.60' cy='216.13' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='279.96' cy='168.36' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='231.89' cy='302.13' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='263.94' cy='206.58' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='258.60' cy='283.02' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='269.28' cy='225.69' r='5.67' style='stroke-width: 0.75;' />
+<circle cx='194.50' cy='359.47' r='4.77' style='stroke-width: 0.75;' />
+<circle cx='263.94' cy='197.02' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='226.55' cy='330.80' r='5.39' style='stroke-width: 0.75;' />
+<circle cx='205.18' cy='349.91' r='4.77' style='stroke-width: 0.75;' />
+<circle cx='242.57' cy='263.91' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='231.89' cy='254.36' r='4.77' style='stroke-width: 0.75;' />
+<circle cx='269.28' cy='244.80' r='5.39' style='stroke-width: 0.75;' />
+<circle cx='210.53' cy='292.58' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='253.26' cy='187.47' r='5.39' style='stroke-width: 0.75;' />
+<circle cx='258.60' cy='292.58' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='237.23' cy='273.47' r='4.77' style='stroke-width: 0.75;' />
+<circle cx='258.60' cy='235.24' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='226.55' cy='292.58' r='4.93' style='stroke-width: 0.75;' />
+<circle cx='274.62' cy='263.91' r='5.93' style='stroke-width: 0.75;' />
+<circle cx='231.89' cy='244.80' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='279.96' cy='225.69' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='269.28' cy='244.80' r='5.09' style='stroke-width: 0.75;' />
+<circle cx='247.92' cy='216.13' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='253.26' cy='197.02' r='5.39' style='stroke-width: 0.75;' />
+<circle cx='274.62' cy='177.91' r='5.39' style='stroke-width: 0.75;' />
+<circle cx='285.31' cy='187.47' r='5.80' style='stroke-width: 0.75;' />
+<circle cx='258.60' cy='254.36' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='205.18' cy='283.02' r='4.77' style='stroke-width: 0.75;' />
+<circle cx='221.21' cy='302.13' r='4.93' style='stroke-width: 0.75;' />
+<circle cx='215.87' cy='302.13' r='4.77' style='stroke-width: 0.75;' />
+<circle cx='226.55' cy='273.47' r='5.09' style='stroke-width: 0.75;' />
+<circle cx='290.65' cy='254.36' r='5.67' style='stroke-width: 0.75;' />
+<circle cx='258.60' cy='311.69' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='258.60' cy='254.36' r='5.67' style='stroke-width: 0.75;' />
+<circle cx='269.28' cy='187.47' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='253.26' cy='225.69' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='237.23' cy='292.58' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='231.89' cy='302.13' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='253.26' cy='302.13' r='5.09' style='stroke-width: 0.75;' />
+<circle cx='263.94' cy='244.80' r='5.39' style='stroke-width: 0.75;' />
+<circle cx='231.89' cy='273.47' r='5.09' style='stroke-width: 0.75;' />
+<circle cx='194.50' cy='349.91' r='4.77' style='stroke-width: 0.75;' />
+<circle cx='242.57' cy='292.58' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='242.57' cy='283.02' r='5.09' style='stroke-width: 0.75;' />
+<circle cx='242.57' cy='283.02' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='247.92' cy='235.24' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='178.48' cy='340.36' r='4.93' style='stroke-width: 0.75;' />
+<circle cx='237.23' cy='283.02' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='338.72' cy='225.69' r='6.75' style='stroke-width: 0.75;' />
+<circle cx='290.65' cy='273.47' r='6.06' style='stroke-width: 0.75;' />
+<circle cx='333.38' cy='149.24' r='6.30' style='stroke-width: 0.75;' />
+<circle cx='317.36' cy='225.69' r='5.93' style='stroke-width: 0.75;' />
+<circle cx='328.04' cy='206.58' r='6.42' style='stroke-width: 0.75;' />
+<circle cx='370.77' cy='101.47' r='6.30' style='stroke-width: 0.75;' />
+<circle cx='258.60' cy='359.47' r='5.80' style='stroke-width: 0.75;' />
+<circle cx='354.75' cy='130.13' r='5.93' style='stroke-width: 0.75;' />
+<circle cx='328.04' cy='187.47' r='5.93' style='stroke-width: 0.75;' />
+<circle cx='344.06' cy='139.69' r='6.75' style='stroke-width: 0.75;' />
+<circle cx='290.65' cy='206.58' r='6.18' style='stroke-width: 0.75;' />
+<circle cx='301.33' cy='216.13' r='6.06' style='stroke-width: 0.75;' />
+<circle cx='312.01' cy='177.91' r='6.30' style='stroke-width: 0.75;' />
+<circle cx='285.31' cy='283.02' r='6.18' style='stroke-width: 0.75;' />
+<circle cx='290.65' cy='273.47' r='6.64' style='stroke-width: 0.75;' />
+<circle cx='301.33' cy='216.13' r='6.53' style='stroke-width: 0.75;' />
+<circle cx='312.01' cy='206.58' r='5.93' style='stroke-width: 0.75;' />
+<circle cx='376.11' cy='91.91' r='6.42' style='stroke-width: 0.75;' />
+<circle cx='386.79' cy='91.91' r='6.53' style='stroke-width: 0.75;' />
+<circle cx='285.31' cy='254.36' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='322.70' cy='168.36' r='6.53' style='stroke-width: 0.75;' />
+<circle cx='279.96' cy='292.58' r='6.18' style='stroke-width: 0.75;' />
+<circle cx='376.11' cy='91.91' r='6.18' style='stroke-width: 0.75;' />
+<circle cx='279.96' cy='225.69' r='5.93' style='stroke-width: 0.75;' />
+<circle cx='322.70' cy='187.47' r='6.30' style='stroke-width: 0.75;' />
+<circle cx='338.72' cy='139.69' r='5.93' style='stroke-width: 0.75;' />
+<circle cx='274.62' cy='235.24' r='5.93' style='stroke-width: 0.75;' />
+<circle cx='279.96' cy='244.80' r='5.93' style='stroke-width: 0.75;' />
+<circle cx='317.36' cy='216.13' r='6.30' style='stroke-width: 0.75;' />
+<circle cx='328.04' cy='139.69' r='5.67' style='stroke-width: 0.75;' />
+<circle cx='344.06' cy='120.58' r='6.06' style='stroke-width: 0.75;' />
+<circle cx='360.09' cy='72.80' r='6.18' style='stroke-width: 0.75;' />
+<circle cx='317.36' cy='216.13' r='6.42' style='stroke-width: 0.75;' />
+<circle cx='290.65' cy='225.69' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='317.36' cy='244.80' r='5.39' style='stroke-width: 0.75;' />
+<circle cx='344.06' cy='91.91' r='6.53' style='stroke-width: 0.75;' />
+<circle cx='317.36' cy='225.69' r='6.64' style='stroke-width: 0.75;' />
+<circle cx='312.01' cy='216.13' r='5.93' style='stroke-width: 0.75;' />
+<circle cx='274.62' cy='254.36' r='5.93' style='stroke-width: 0.75;' />
+<circle cx='306.67' cy='168.36' r='6.30' style='stroke-width: 0.75;' />
+<circle cx='317.36' cy='187.47' r='6.64' style='stroke-width: 0.75;' />
+<circle cx='290.65' cy='168.36' r='6.53' style='stroke-width: 0.75;' />
+<circle cx='290.65' cy='273.47' r='6.06' style='stroke-width: 0.75;' />
+<circle cx='333.38' cy='177.91' r='6.53' style='stroke-width: 0.75;' />
+<circle cx='322.70' cy='187.47' r='6.75' style='stroke-width: 0.75;' />
+<circle cx='295.99' cy='187.47' r='6.53' style='stroke-width: 0.75;' />
+<circle cx='285.31' cy='225.69' r='6.06' style='stroke-width: 0.75;' />
+<circle cx='295.99' cy='206.58' r='6.18' style='stroke-width: 0.75;' />
+<circle cx='306.67' cy='235.24' r='6.53' style='stroke-width: 0.75;' />
+<circle cx='290.65' cy='263.91' r='5.93' style='stroke-width: 0.75;' />
+</g>
+</g>
+</svg>
diff --git a/inst/tinytest/_tinysnapshot/bubble_simple_bottom.svg b/inst/tinytest/_tinysnapshot/bubble_simple_bottom.svg
new file mode 100644
index 00000000..7219735b
--- /dev/null
+++ b/inst/tinytest/_tinysnapshot/bubble_simple_bottom.svg
@@ -0,0 +1,245 @@
+<?xml version='1.0' encoding='UTF-8' ?>
+<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='504.00pt' height='504.00pt' viewBox='0 0 504.00 504.00'>
+<g class='svglite'>
+<defs>
+  <style type='text/css'><![CDATA[
+    .svglite line, .svglite polyline, .svglite polygon, .svglite path, .svglite rect, .svglite circle {
+      fill: none;
+      stroke: #000000;
+      stroke-linecap: round;
+      stroke-linejoin: round;
+      stroke-miterlimit: 10.00;
+    }
+    .svglite text {
+      white-space: pre;
+    }
+    .svglite g.glyphgroup path {
+      fill: inherit;
+      stroke: none;
+    }
+  ]]></style>
+</defs>
+<rect width='100%' height='100%' style='stroke: none; fill: #FFFFFF;'/>
+<defs>
+  <clipPath id='cpMC4wMHw1MDQuMDB8MC4wMHw1MDQuMDA='>
+    <rect x='0.00' y='0.00' width='504.00' height='504.00' />
+  </clipPath>
+</defs>
+<g clip-path='url(#cpMC4wMHw1MDQuMDB8MC4wMHw1MDQuMDA=)'>
+<circle cx='143.04' cy='488.16' r='1.35' style='stroke-width: 0.75;' />
+<circle cx='187.97' cy='488.16' r='3.76' style='stroke-width: 0.75;' />
+<circle cx='232.90' cy='488.16' r='4.77' style='stroke-width: 0.75;' />
+<circle cx='277.83' cy='488.16' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='322.76' cy='488.16' r='6.18' style='stroke-width: 0.75;' />
+<circle cx='367.68' cy='488.16' r='6.75' style='stroke-width: 0.75;' />
+<text x='266.40' y='473.76' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='83.36px' lengthAdjust='spacingAndGlyphs'>iris$Petal.Width</text>
+<text x='153.84' y='492.29' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='23.33px' lengthAdjust='spacingAndGlyphs'>0.0  </text>
+<text x='198.77' y='492.29' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='23.33px' lengthAdjust='spacingAndGlyphs'>0.5  </text>
+<text x='243.70' y='492.29' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='23.33px' lengthAdjust='spacingAndGlyphs'>1.0  </text>
+<text x='288.63' y='492.29' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='23.33px' lengthAdjust='spacingAndGlyphs'>1.5  </text>
+<text x='333.56' y='492.29' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='23.33px' lengthAdjust='spacingAndGlyphs'>2.0  </text>
+<text x='378.48' y='492.29' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>2.5</text>
+</g>
+<defs>
+  <clipPath id='cpMC4wMHw1MDQuMDB8MC4wMHw0NDQuOTY='>
+    <rect x='0.00' y='0.00' width='504.00' height='444.96' />
+  </clipPath>
+</defs>
+<g clip-path='url(#cpMC4wMHw1MDQuMDB8MC4wMHw0NDQuOTY=)'>
+<text x='266.40' y='442.08' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='67.36px' lengthAdjust='spacingAndGlyphs'>Petal.Length</text>
+<text transform='translate(12.96,223.20) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='70.70px' lengthAdjust='spacingAndGlyphs'>Sepal.Length</text>
+</g>
+<g clip-path='url(#cpMC4wMHw1MDQuMDB8MC4wMHw1MDQuMDA=)'>
+<line x1='74.40' y1='387.36' x2='464.91' y2='387.36' style='stroke-width: 0.75;' />
+<line x1='74.40' y1='387.36' x2='74.40' y2='394.56' style='stroke-width: 0.75;' />
+<line x1='139.48' y1='387.36' x2='139.48' y2='394.56' style='stroke-width: 0.75;' />
+<line x1='204.57' y1='387.36' x2='204.57' y2='394.56' style='stroke-width: 0.75;' />
+<line x1='269.65' y1='387.36' x2='269.65' y2='394.56' style='stroke-width: 0.75;' />
+<line x1='334.74' y1='387.36' x2='334.74' y2='394.56' style='stroke-width: 0.75;' />
+<line x1='399.82' y1='387.36' x2='399.82' y2='394.56' style='stroke-width: 0.75;' />
+<line x1='464.91' y1='387.36' x2='464.91' y2='394.56' style='stroke-width: 0.75;' />
+<text x='74.40' y='413.28' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>1</text>
+<text x='139.48' y='413.28' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>2</text>
+<text x='204.57' y='413.28' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>3</text>
+<text x='269.65' y='413.28' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>4</text>
+<text x='334.74' y='413.28' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>5</text>
+<text x='399.82' y='413.28' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>6</text>
+<text x='464.91' y='413.28' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>7</text>
+<line x1='59.04' y1='358.31' x2='59.04' y2='62.76' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='358.31' x2='51.84' y2='358.31' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='316.09' x2='51.84' y2='316.09' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='273.87' x2='51.84' y2='273.87' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='231.64' x2='51.84' y2='231.64' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='189.42' x2='51.84' y2='189.42' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='147.20' x2='51.84' y2='147.20' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='104.98' x2='51.84' y2='104.98' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='62.76' x2='51.84' y2='62.76' style='stroke-width: 0.75;' />
+<text transform='translate(41.76,358.31) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>4.5</text>
+<text transform='translate(41.76,316.09) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>5.0</text>
+<text transform='translate(41.76,273.87) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>5.5</text>
+<text transform='translate(41.76,231.64) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>6.0</text>
+<text transform='translate(41.76,189.42) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>6.5</text>
+<text transform='translate(41.76,147.20) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>7.0</text>
+<text transform='translate(41.76,104.98) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>7.5</text>
+<text transform='translate(41.76,62.76) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>8.0</text>
+<polygon points='59.04,387.36 473.76,387.36 473.76,59.04 59.04,59.04 ' style='stroke-width: 0.75;' />
+</g>
+<defs>
+  <clipPath id='cpNTkuMDR8NDczLjc2fDU5LjA0fDM4Ny4zNg=='>
+    <rect x='59.04' y='59.04' width='414.72' height='328.32' />
+  </clipPath>
+</defs>
+<g clip-path='url(#cpNTkuMDR8NDczLjc2fDU5LjA0fDM4Ny4zNg==)'>
+<circle cx='100.43' cy='307.64' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='100.43' cy='324.53' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='93.93' cy='341.42' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='106.94' cy='349.87' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='100.43' cy='316.09' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='119.96' cy='282.31' r='3.51' style='stroke-width: 0.75;' />
+<circle cx='100.43' cy='349.87' r='3.22' style='stroke-width: 0.75;' />
+<circle cx='106.94' cy='316.09' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='100.43' cy='366.76' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='106.94' cy='324.53' r='2.43' style='stroke-width: 0.75;' />
+<circle cx='106.94' cy='282.31' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='113.45' cy='332.98' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='100.43' cy='332.98' r='2.43' style='stroke-width: 0.75;' />
+<circle cx='80.91' cy='375.20' r='2.43' style='stroke-width: 0.75;' />
+<circle cx='87.42' cy='248.53' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='106.94' cy='256.98' r='3.51' style='stroke-width: 0.75;' />
+<circle cx='93.93' cy='282.31' r='3.51' style='stroke-width: 0.75;' />
+<circle cx='100.43' cy='307.64' r='3.22' style='stroke-width: 0.75;' />
+<circle cx='119.96' cy='256.98' r='3.22' style='stroke-width: 0.75;' />
+<circle cx='106.94' cy='307.64' r='3.22' style='stroke-width: 0.75;' />
+<circle cx='119.96' cy='282.31' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='106.94' cy='307.64' r='3.51' style='stroke-width: 0.75;' />
+<circle cx='74.40' cy='349.87' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='119.96' cy='307.64' r='3.76' style='stroke-width: 0.75;' />
+<circle cx='132.98' cy='332.98' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='113.45' cy='316.09' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='113.45' cy='316.09' r='3.51' style='stroke-width: 0.75;' />
+<circle cx='106.94' cy='299.20' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='100.43' cy='299.20' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='113.45' cy='341.42' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='113.45' cy='332.98' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='106.94' cy='282.31' r='3.51' style='stroke-width: 0.75;' />
+<circle cx='106.94' cy='299.20' r='2.43' style='stroke-width: 0.75;' />
+<circle cx='100.43' cy='273.87' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='106.94' cy='324.53' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='87.42' cy='316.09' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='93.93' cy='273.87' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='100.43' cy='324.53' r='2.43' style='stroke-width: 0.75;' />
+<circle cx='93.93' cy='366.76' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='106.94' cy='307.64' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='93.93' cy='316.09' r='3.22' style='stroke-width: 0.75;' />
+<circle cx='93.93' cy='358.31' r='3.22' style='stroke-width: 0.75;' />
+<circle cx='93.93' cy='366.76' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='113.45' cy='316.09' r='4.00' style='stroke-width: 0.75;' />
+<circle cx='132.98' cy='307.64' r='3.51' style='stroke-width: 0.75;' />
+<circle cx='100.43' cy='332.98' r='3.22' style='stroke-width: 0.75;' />
+<circle cx='113.45' cy='307.64' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='100.43' cy='349.87' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='106.94' cy='290.76' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='100.43' cy='316.09' r='2.88' style='stroke-width: 0.75;' />
+<circle cx='315.21' cy='147.20' r='5.39' style='stroke-width: 0.75;' />
+<circle cx='302.20' cy='197.87' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='328.23' cy='155.64' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='269.65' cy='273.87' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='308.71' cy='189.42' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='302.20' cy='256.98' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='315.21' cy='206.31' r='5.67' style='stroke-width: 0.75;' />
+<circle cx='224.09' cy='324.53' r='4.77' style='stroke-width: 0.75;' />
+<circle cx='308.71' cy='180.98' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='263.15' cy='299.20' r='5.39' style='stroke-width: 0.75;' />
+<circle cx='237.11' cy='316.09' r='4.77' style='stroke-width: 0.75;' />
+<circle cx='282.67' cy='240.09' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='269.65' cy='231.64' r='4.77' style='stroke-width: 0.75;' />
+<circle cx='315.21' cy='223.20' r='5.39' style='stroke-width: 0.75;' />
+<circle cx='243.62' cy='265.42' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='295.69' cy='172.53' r='5.39' style='stroke-width: 0.75;' />
+<circle cx='302.20' cy='265.42' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='276.16' cy='248.53' r='4.77' style='stroke-width: 0.75;' />
+<circle cx='302.20' cy='214.76' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='263.15' cy='265.42' r='4.93' style='stroke-width: 0.75;' />
+<circle cx='321.72' cy='240.09' r='5.93' style='stroke-width: 0.75;' />
+<circle cx='269.65' cy='223.20' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='328.23' cy='206.31' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='315.21' cy='223.20' r='5.09' style='stroke-width: 0.75;' />
+<circle cx='289.18' cy='197.87' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='295.69' cy='180.98' r='5.39' style='stroke-width: 0.75;' />
+<circle cx='321.72' cy='164.09' r='5.39' style='stroke-width: 0.75;' />
+<circle cx='334.74' cy='172.53' r='5.80' style='stroke-width: 0.75;' />
+<circle cx='302.20' cy='231.64' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='237.11' cy='256.98' r='4.77' style='stroke-width: 0.75;' />
+<circle cx='256.64' cy='273.87' r='4.93' style='stroke-width: 0.75;' />
+<circle cx='250.13' cy='273.87' r='4.77' style='stroke-width: 0.75;' />
+<circle cx='263.15' cy='248.53' r='5.09' style='stroke-width: 0.75;' />
+<circle cx='341.25' cy='231.64' r='5.67' style='stroke-width: 0.75;' />
+<circle cx='302.20' cy='282.31' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='302.20' cy='231.64' r='5.67' style='stroke-width: 0.75;' />
+<circle cx='315.21' cy='172.53' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='295.69' cy='206.31' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='276.16' cy='265.42' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='269.65' cy='273.87' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='295.69' cy='273.87' r='5.09' style='stroke-width: 0.75;' />
+<circle cx='308.71' cy='223.20' r='5.39' style='stroke-width: 0.75;' />
+<circle cx='269.65' cy='248.53' r='5.09' style='stroke-width: 0.75;' />
+<circle cx='224.09' cy='316.09' r='4.77' style='stroke-width: 0.75;' />
+<circle cx='282.67' cy='265.42' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='282.67' cy='256.98' r='5.09' style='stroke-width: 0.75;' />
+<circle cx='282.67' cy='256.98' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='289.18' cy='214.76' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='204.57' cy='307.64' r='4.93' style='stroke-width: 0.75;' />
+<circle cx='276.16' cy='256.98' r='5.24' style='stroke-width: 0.75;' />
+<circle cx='399.82' cy='206.31' r='6.75' style='stroke-width: 0.75;' />
+<circle cx='341.25' cy='248.53' r='6.06' style='stroke-width: 0.75;' />
+<circle cx='393.32' cy='138.76' r='6.30' style='stroke-width: 0.75;' />
+<circle cx='373.79' cy='206.31' r='5.93' style='stroke-width: 0.75;' />
+<circle cx='386.81' cy='189.42' r='6.42' style='stroke-width: 0.75;' />
+<circle cx='438.87' cy='96.53' r='6.30' style='stroke-width: 0.75;' />
+<circle cx='302.20' cy='324.53' r='5.80' style='stroke-width: 0.75;' />
+<circle cx='419.35' cy='121.87' r='5.93' style='stroke-width: 0.75;' />
+<circle cx='386.81' cy='172.53' r='5.93' style='stroke-width: 0.75;' />
+<circle cx='406.33' cy='130.31' r='6.75' style='stroke-width: 0.75;' />
+<circle cx='341.25' cy='189.42' r='6.18' style='stroke-width: 0.75;' />
+<circle cx='354.26' cy='197.87' r='6.06' style='stroke-width: 0.75;' />
+<circle cx='367.28' cy='164.09' r='6.30' style='stroke-width: 0.75;' />
+<circle cx='334.74' cy='256.98' r='6.18' style='stroke-width: 0.75;' />
+<circle cx='341.25' cy='248.53' r='6.64' style='stroke-width: 0.75;' />
+<circle cx='354.26' cy='197.87' r='6.53' style='stroke-width: 0.75;' />
+<circle cx='367.28' cy='189.42' r='5.93' style='stroke-width: 0.75;' />
+<circle cx='445.38' cy='88.09' r='6.42' style='stroke-width: 0.75;' />
+<circle cx='458.40' cy='88.09' r='6.53' style='stroke-width: 0.75;' />
+<circle cx='334.74' cy='231.64' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='380.30' cy='155.64' r='6.53' style='stroke-width: 0.75;' />
+<circle cx='328.23' cy='265.42' r='6.18' style='stroke-width: 0.75;' />
+<circle cx='445.38' cy='88.09' r='6.18' style='stroke-width: 0.75;' />
+<circle cx='328.23' cy='206.31' r='5.93' style='stroke-width: 0.75;' />
+<circle cx='380.30' cy='172.53' r='6.30' style='stroke-width: 0.75;' />
+<circle cx='399.82' cy='130.31' r='5.93' style='stroke-width: 0.75;' />
+<circle cx='321.72' cy='214.76' r='5.93' style='stroke-width: 0.75;' />
+<circle cx='328.23' cy='223.20' r='5.93' style='stroke-width: 0.75;' />
+<circle cx='373.79' cy='197.87' r='6.30' style='stroke-width: 0.75;' />
+<circle cx='386.81' cy='130.31' r='5.67' style='stroke-width: 0.75;' />
+<circle cx='406.33' cy='113.42' r='6.06' style='stroke-width: 0.75;' />
+<circle cx='425.86' cy='71.20' r='6.18' style='stroke-width: 0.75;' />
+<circle cx='373.79' cy='197.87' r='6.42' style='stroke-width: 0.75;' />
+<circle cx='341.25' cy='206.31' r='5.53' style='stroke-width: 0.75;' />
+<circle cx='373.79' cy='223.20' r='5.39' style='stroke-width: 0.75;' />
+<circle cx='406.33' cy='88.09' r='6.53' style='stroke-width: 0.75;' />
+<circle cx='373.79' cy='206.31' r='6.64' style='stroke-width: 0.75;' />
+<circle cx='367.28' cy='197.87' r='5.93' style='stroke-width: 0.75;' />
+<circle cx='321.72' cy='231.64' r='5.93' style='stroke-width: 0.75;' />
+<circle cx='360.77' cy='155.64' r='6.30' style='stroke-width: 0.75;' />
+<circle cx='373.79' cy='172.53' r='6.64' style='stroke-width: 0.75;' />
+<circle cx='341.25' cy='155.64' r='6.53' style='stroke-width: 0.75;' />
+<circle cx='341.25' cy='248.53' r='6.06' style='stroke-width: 0.75;' />
+<circle cx='393.32' cy='164.09' r='6.53' style='stroke-width: 0.75;' />
+<circle cx='380.30' cy='172.53' r='6.75' style='stroke-width: 0.75;' />
+<circle cx='347.76' cy='172.53' r='6.53' style='stroke-width: 0.75;' />
+<circle cx='334.74' cy='206.31' r='6.06' style='stroke-width: 0.75;' />
+<circle cx='347.76' cy='189.42' r='6.18' style='stroke-width: 0.75;' />
+<circle cx='360.77' cy='214.76' r='6.53' style='stroke-width: 0.75;' />
+<circle cx='341.25' cy='240.09' r='5.93' style='stroke-width: 0.75;' />
+</g>
+</g>
+</svg>
diff --git a/inst/tinytest/_tinysnapshot/bubble_simple_clim.svg b/inst/tinytest/_tinysnapshot/bubble_simple_clim.svg
new file mode 100644
index 00000000..a4b39e67
--- /dev/null
+++ b/inst/tinytest/_tinysnapshot/bubble_simple_clim.svg
@@ -0,0 +1,245 @@
+<?xml version='1.0' encoding='UTF-8' ?>
+<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='504.00pt' height='504.00pt' viewBox='0 0 504.00 504.00'>
+<g class='svglite'>
+<defs>
+  <style type='text/css'><![CDATA[
+    .svglite line, .svglite polyline, .svglite polygon, .svglite path, .svglite rect, .svglite circle {
+      fill: none;
+      stroke: #000000;
+      stroke-linecap: round;
+      stroke-linejoin: round;
+      stroke-miterlimit: 10.00;
+    }
+    .svglite text {
+      white-space: pre;
+    }
+    .svglite g.glyphgroup path {
+      fill: inherit;
+      stroke: none;
+    }
+  ]]></style>
+</defs>
+<rect width='100%' height='100%' style='stroke: none; fill: #FFFFFF;'/>
+<defs>
+  <clipPath id='cpMC4wMHw1MDQuMDB8MC4wMHw1MDQuMDA='>
+    <rect x='0.00' y='0.00' width='504.00' height='504.00' />
+  </clipPath>
+</defs>
+<g clip-path='url(#cpMC4wMHw1MDQuMDB8MC4wMHw1MDQuMDA=)'>
+<circle cx='439.58' cy='183.62' r='2.70' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='439.58' cy='200.14' r='8.74' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='439.58' cy='221.45' r='11.24' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='439.58' cy='247.47' r='13.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='439.58' cy='277.26' r='14.77' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='439.58' cy='310.30' r='16.20' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<text x='458.18' y='169.22' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='83.36px' lengthAdjust='spacingAndGlyphs'>iris$Petal.Width</text>
+<text x='465.50' y='187.75' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>0.0</text>
+<text x='465.50' y='204.27' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>0.5</text>
+<text x='465.50' y='225.58' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>1.0</text>
+<text x='465.50' y='251.60' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>1.5</text>
+<text x='465.50' y='281.39' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>2.0</text>
+<text x='465.50' y='314.43' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>2.5</text>
+</g>
+<defs>
+  <clipPath id='cpMC4wMHwzOTkuNDB8MC4wMHw1MDQuMDA='>
+    <rect x='0.00' y='0.00' width='399.40' height='504.00' />
+  </clipPath>
+</defs>
+<g clip-path='url(#cpMC4wMHwzOTkuNDB8MC4wMHw1MDQuMDA=)'>
+<text x='229.22' y='485.28' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='67.36px' lengthAdjust='spacingAndGlyphs'>Petal.Length</text>
+<text transform='translate(12.96,244.80) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='70.70px' lengthAdjust='spacingAndGlyphs'>Sepal.Length</text>
+</g>
+<g clip-path='url(#cpMC4wMHw1MDQuMDB8MC4wMHw1MDQuMDA=)'>
+<line x1='71.65' y1='430.56' x2='392.14' y2='430.56' style='stroke-width: 0.75;' />
+<line x1='71.65' y1='430.56' x2='71.65' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='125.06' y1='430.56' x2='125.06' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='178.48' y1='430.56' x2='178.48' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='231.89' y1='430.56' x2='231.89' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='285.31' y1='430.56' x2='285.31' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='338.72' y1='430.56' x2='338.72' y2='437.76' style='stroke-width: 0.75;' />
+<line x1='392.14' y1='430.56' x2='392.14' y2='437.76' style='stroke-width: 0.75;' />
+<text x='71.65' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>1</text>
+<text x='125.06' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>2</text>
+<text x='178.48' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>3</text>
+<text x='231.89' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>4</text>
+<text x='285.31' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>5</text>
+<text x='338.72' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>6</text>
+<text x='392.14' y='456.48' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='6.67px' lengthAdjust='spacingAndGlyphs'>7</text>
+<line x1='59.04' y1='397.69' x2='59.04' y2='63.24' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='397.69' x2='51.84' y2='397.69' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='349.91' x2='51.84' y2='349.91' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='302.13' x2='51.84' y2='302.13' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='254.36' x2='51.84' y2='254.36' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='206.58' x2='51.84' y2='206.58' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='158.80' x2='51.84' y2='158.80' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='111.02' x2='51.84' y2='111.02' style='stroke-width: 0.75;' />
+<line x1='59.04' y1='63.24' x2='51.84' y2='63.24' style='stroke-width: 0.75;' />
+<text transform='translate(41.76,397.69) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>4.5</text>
+<text transform='translate(41.76,349.91) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>5.0</text>
+<text transform='translate(41.76,302.13) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>5.5</text>
+<text transform='translate(41.76,254.36) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>6.0</text>
+<text transform='translate(41.76,206.58) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>6.5</text>
+<text transform='translate(41.76,158.80) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>7.0</text>
+<text transform='translate(41.76,111.02) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>7.5</text>
+<text transform='translate(41.76,63.24) rotate(-90)' text-anchor='middle' style='font-size: 12.00px; font-family: "Liberation Sans";' textLength='16.67px' lengthAdjust='spacingAndGlyphs'>8.0</text>
+<polygon points='59.04,430.56 399.40,430.56 399.40,59.04 59.04,59.04 ' style='stroke-width: 0.75;' />
+</g>
+<defs>
+  <clipPath id='cpNTkuMDR8Mzk5LjQwfDU5LjA0fDQzMC41Ng=='>
+    <rect x='59.04' y='59.04' width='340.36' height='371.52' />
+  </clipPath>
+</defs>
+<g clip-path='url(#cpNTkuMDR8Mzk5LjQwfDU5LjA0fDQzMC41Ng==)'>
+<circle cx='93.01' cy='340.36' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='93.01' cy='359.47' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='87.67' cy='378.58' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='98.35' cy='388.13' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='93.01' cy='349.91' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='109.04' cy='311.69' r='8.10' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='93.01' cy='388.13' r='7.38' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='98.35' cy='349.91' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='93.01' cy='407.24' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='98.35' cy='359.47' r='5.40' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='98.35' cy='311.69' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='103.69' cy='369.02' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='93.01' cy='369.02' r='5.40' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='76.99' cy='416.80' r='5.40' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='82.33' cy='273.47' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='98.35' cy='283.02' r='8.10' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='87.67' cy='311.69' r='8.10' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='93.01' cy='340.36' r='7.38' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='109.04' cy='283.02' r='7.38' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='98.35' cy='340.36' r='7.38' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='109.04' cy='311.69' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='98.35' cy='340.36' r='8.10' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='71.65' cy='388.13' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='109.04' cy='340.36' r='8.74' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='119.72' cy='369.02' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='103.69' cy='349.91' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='103.69' cy='349.91' r='8.10' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='98.35' cy='330.80' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='93.01' cy='330.80' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='103.69' cy='378.58' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='103.69' cy='369.02' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='98.35' cy='311.69' r='8.10' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='98.35' cy='330.80' r='5.40' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='93.01' cy='302.13' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='98.35' cy='359.47' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='82.33' cy='349.91' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='87.67' cy='302.13' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='93.01' cy='359.47' r='5.40' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='87.67' cy='407.24' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='98.35' cy='340.36' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='87.67' cy='349.91' r='7.38' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='87.67' cy='397.69' r='7.38' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='87.67' cy='407.24' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='103.69' cy='349.91' r='9.31' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='119.72' cy='340.36' r='8.10' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='93.01' cy='369.02' r='7.38' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='103.69' cy='340.36' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='93.01' cy='388.13' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='98.35' cy='321.24' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='93.01' cy='349.91' r='6.52' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='269.28' cy='158.80' r='12.80' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='258.60' cy='216.13' r='13.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='279.96' cy='168.36' r='13.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='231.89' cy='302.13' r='12.43' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='263.94' cy='206.58' r='13.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='258.60' cy='283.02' r='12.43' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='269.28' cy='225.69' r='13.50' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='194.50' cy='359.47' r='11.24' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='263.94' cy='197.02' r='12.43' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='226.55' cy='330.80' r='12.80' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='205.18' cy='349.91' r='11.24' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='242.57' cy='263.91' r='13.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='231.89' cy='254.36' r='11.24' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='269.28' cy='244.80' r='12.80' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='210.53' cy='292.58' r='12.43' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='253.26' cy='187.47' r='12.80' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='258.60' cy='292.58' r='13.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='237.23' cy='273.47' r='11.24' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='258.60' cy='235.24' r='13.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='226.55' cy='292.58' r='11.65' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='274.62' cy='263.91' r='14.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='231.89' cy='244.80' r='12.43' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='279.96' cy='225.69' r='13.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='269.28' cy='244.80' r='12.05' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='247.92' cy='216.13' r='12.43' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='253.26' cy='197.02' r='12.80' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='274.62' cy='177.91' r='12.80' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='285.31' cy='187.47' r='13.83' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='258.60' cy='254.36' r='13.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='205.18' cy='283.02' r='11.24' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='221.21' cy='302.13' r='11.65' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='215.87' cy='302.13' r='11.24' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='226.55' cy='273.47' r='12.05' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='290.65' cy='254.36' r='13.50' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='258.60' cy='311.69' r='13.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='258.60' cy='254.36' r='13.50' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='269.28' cy='187.47' r='13.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='253.26' cy='225.69' r='12.43' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='237.23' cy='292.58' r='12.43' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='231.89' cy='302.13' r='12.43' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='253.26' cy='302.13' r='12.05' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='263.94' cy='244.80' r='12.80' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='231.89' cy='273.47' r='12.05' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='194.50' cy='349.91' r='11.24' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='242.57' cy='292.58' r='12.43' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='242.57' cy='283.02' r='12.05' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='242.57' cy='283.02' r='12.43' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='247.92' cy='235.24' r='12.43' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='178.48' cy='340.36' r='11.65' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='237.23' cy='283.02' r='12.43' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='338.72' cy='225.69' r='16.20' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='290.65' cy='273.47' r='14.47' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='333.38' cy='149.24' r='15.07' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='317.36' cy='225.69' r='14.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='328.04' cy='206.58' r='15.36' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='370.77' cy='101.47' r='15.07' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='258.60' cy='359.47' r='13.83' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='354.75' cy='130.13' r='14.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='328.04' cy='187.47' r='14.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='344.06' cy='139.69' r='16.20' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='290.65' cy='206.58' r='14.77' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='301.33' cy='216.13' r='14.47' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='312.01' cy='177.91' r='15.07' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='285.31' cy='283.02' r='14.77' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='290.65' cy='273.47' r='15.93' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='301.33' cy='216.13' r='15.65' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='312.01' cy='206.58' r='14.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='376.11' cy='91.91' r='15.36' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='386.79' cy='91.91' r='15.65' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='285.31' cy='254.36' r='13.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='322.70' cy='168.36' r='15.65' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='279.96' cy='292.58' r='14.77' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='376.11' cy='91.91' r='14.77' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='279.96' cy='225.69' r='14.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='322.70' cy='187.47' r='15.07' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='338.72' cy='139.69' r='14.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='274.62' cy='235.24' r='14.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='279.96' cy='244.80' r='14.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='317.36' cy='216.13' r='15.07' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='328.04' cy='139.69' r='13.50' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='344.06' cy='120.58' r='14.47' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='360.09' cy='72.80' r='14.77' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='317.36' cy='216.13' r='15.36' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='290.65' cy='225.69' r='13.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='317.36' cy='244.80' r='12.80' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='344.06' cy='91.91' r='15.65' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='317.36' cy='225.69' r='15.93' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='312.01' cy='216.13' r='14.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='274.62' cy='254.36' r='14.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='306.67' cy='168.36' r='15.07' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='317.36' cy='187.47' r='15.93' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='290.65' cy='168.36' r='15.65' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='290.65' cy='273.47' r='14.47' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='333.38' cy='177.91' r='15.65' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='322.70' cy='187.47' r='16.20' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='295.99' cy='187.47' r='15.65' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='285.31' cy='225.69' r='14.47' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='295.99' cy='206.58' r='14.77' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='306.67' cy='235.24' r='15.65' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+<circle cx='290.65' cy='263.91' r='14.16' style='stroke-width: 0.75; stroke: #000000; stroke-opacity: 0.30; fill: #000000; fill-opacity: 0.30;' />
+</g>
+</g>
+</svg>
diff --git a/inst/tinytest/test-bubble.R b/inst/tinytest/test-bubble.R
new file mode 100644
index 00000000..7feb0abf
--- /dev/null
+++ b/inst/tinytest/test-bubble.R
@@ -0,0 +1,70 @@
+source("helpers.R")
+using("tinysnapshot")
+
+#
+## single (combined) legend -----
+#
+
+# no by groups & continuous cex
+f = function() tinyplot(
+  Sepal.Length ~ Petal.Length,
+  data = iris,
+  cex = iris$Petal.Width
+)
+expect_snapshot_plot(f, label = "bubble_simple")
+
+# ditto, but legend at bottom
+f = function() tinyplot(
+  Sepal.Length ~ Petal.Length,
+  data = iris,
+  cex = iris$Petal.Width,
+  legend = "bottom!"
+)
+expect_snapshot_plot(f, label = "bubble_simple_bottom")
+
+# check custom expansion range and alpha
+f = function() tinyplot(
+  Sepal.Length ~ Petal.Length,
+  data = iris,
+  cex = iris$Petal.Width, clim = c(1, 6),
+  pch = 19, alpha = 0.3
+)
+expect_snapshot_plot(f, label = "bubble_simple_clim")
+
+# discrete by groups & cex = "by"
+f = function() tinyplot(
+  Sepal.Length ~ Petal.Length | Species,
+  data = iris,
+  cex = "by"
+)
+expect_snapshot_plot(f, label = "bubble_cex_by")
+
+#
+## dual legends -----
+#
+
+# discrete by groups & continuous cex
+f = function() tinyplot(
+  Sepal.Length ~ Petal.Length | Species,
+  data = iris,
+  cex = iris$Petal.Width
+)
+expect_snapshot_plot(f, label = "bubble_dual_discrete")
+
+# continuous x continuous
+f = function() tinyplot(
+  Sepal.Length ~ Petal.Length | Sepal.Width,
+  data = iris,
+  cex = iris$Petal.Width
+)
+expect_snapshot_plot(f, label = "bubble_dual_continuous")
+
+# fancy version with a bunch of customizations
+f = function() tinyplot(
+  Sepal.Length ~ Petal.Length | Sepal.Width,
+  data = iris,
+  cex = iris$Petal.Width, clim = c(1, 6),
+  pch = 22, bg = 0.3,
+  legend = "left!"
+)
+expect_snapshot_plot(f, label = "bubble_dual_fancy")
\ No newline at end of file

From ece3f93e4cd2f53ec705cc49ffd39227f5e67944 Mon Sep 17 00:00:00 2001
From: Grant McDermott <gmcd@amazon.com>
Date: Wed, 3 Sep 2025 16:50:55 -0700
Subject: [PATCH 26/29] fix hidden plot issue

- solution: don't call plot.new() every time. (new_plot should only be TRUE for the first fake legend and FALSE thereafter)
---
 R/draw_legend.R | 25 +++++++++++++++----------
 R/tinyplot.R    | 15 +++++++++------
 2 files changed, 24 insertions(+), 16 deletions(-)

diff --git a/R/draw_legend.R b/R/draw_legend.R
index 3bdda8ea..1313515f 100644
--- a/R/draw_legend.R
+++ b/R/draw_legend.R
@@ -254,7 +254,8 @@ draw_legend = function(
       }
       par(mar = omar)
       
-      if (new_plot && draw) {
+      # if (new_plot && draw) {
+      if (new_plot) {
         plot.new()
         # For themed + dynamic plots, we need to make sure the adjusted plot
         # margins for the legend are reinstated (after being overwritten by
@@ -297,7 +298,8 @@ draw_legend = function(
       }
       par(mar = omar)
 
-      if (new_plot && draw) {
+      # if (new_plot && draw) {
+      if (new_plot) {
         plot.new()
         # For themed + dynamic plots, we need to make sure the adjusted plot
         # margins for the legend are reinstated (after being overwritten by
@@ -323,7 +325,8 @@ draw_legend = function(
     } else {
       
       legend_args[["inset"]] = 0
-      if (new_plot && draw) plot.new()
+      # if (new_plot && draw) plot.new()
+      if (new_plot) plot.new()
       
     }
   
@@ -433,13 +436,15 @@ tinylegend = function(
     if (outer_end) fklgnd.args = modifyList(fklgnd.args, list(title = NULL), keep.null = TRUE)
   }
   
-  if (draw) {
-    fklgnd = do.call("legend", fklgnd.args)
-  } else {
-    plot.new()
-    fklgnd = do.call("legend", fklgnd.args)
-    return(fklgnd)
-  }
+  # if (draw) {
+  #   fklgnd = do.call("legend", fklgnd.args)
+  # } else {
+  #   # plot.new()
+  #   fklgnd = do.call("legend", fklgnd.args)
+  #   return(fklgnd)
+  # }
+  fklgnd = do.call("legend", fklgnd.args)
+  if (!draw) return(fklgnd)
   
   #
   ## Step 2: Calculate legend inset (for outer placement in plot region)
diff --git a/R/tinyplot.R b/R/tinyplot.R
index 92d45bf8..0dffd072 100644
--- a/R/tinyplot.R
+++ b/R/tinyplot.R
@@ -1118,7 +1118,7 @@ tinyplot.default = function(
         lgby_pos = "bottomleft!"
         lgbub_pos = "topleft!"
       }
-      
+
       # first we get the respective legend rect dimensions (i.e., draw = FALSE)
 
       # legend 1: by grouping
@@ -1157,12 +1157,13 @@ tinyplot.default = function(
         pch = pch,
         lty = lty,
         lwd = lwd,
-        col = col,
-        bg = bg,
+        col = "black",
+        # bg = bg,
         gradient = by_continuous,
         cex = bubble_cex * cex_fct_adj,
         has_sub = has_sub,
-        draw = FALSE
+        draw = FALSE,
+        new_plot = FALSE # NB!
       )
 
       # grab respective legend dimensions
@@ -1200,7 +1201,8 @@ tinyplot.default = function(
           col = adjustcolor(par("col"), alpha.f = bubble_alpha),
           bg = adjustcolor(par("col"), alpha.f = bubble_bg_alpha),
           cex = bubble_cex * cex_fct_adj,
-          has_sub = has_sub
+          has_sub = has_sub,
+          new_plot = FALSE # NB!
         )
         ## next, draw by legend (with new_plot = FALSE)
         draw_legend(
@@ -1253,7 +1255,8 @@ tinyplot.default = function(
           gradient = by_continuous,
           # cex = cex * cex_fct_adj,
           cex = lgnd_cex,
-          has_sub = has_sub
+          has_sub = has_sub,
+          new_plot = FALSE # NB!
         )
         ## next, draw bubble legend (with plot_new = FALSE)
         draw_legend(

From 2c2ba13c32ecf8881d4cd148830bec51a900a4f6 Mon Sep 17 00:00:00 2001
From: Grant McDermott <gmcd@amazon.com>
Date: Thu, 4 Sep 2025 15:52:36 -0700
Subject: [PATCH 27/29] dedicated draw_mutli_legend function

---
 R/draw_legend.R          |   2 +-
 R/draw_multi_legend.R    | 133 +++++++++++++++++++++++++++++++
 R/tinyplot.R             | 168 ++++-----------------------------------
 man/draw_multi_legend.Rd |  61 ++++++++++++++
 4 files changed, 210 insertions(+), 154 deletions(-)
 create mode 100644 R/draw_multi_legend.R
 create mode 100644 man/draw_multi_legend.Rd

diff --git a/R/draw_legend.R b/R/draw_legend.R
index 1313515f..28fe1a2f 100644
--- a/R/draw_legend.R
+++ b/R/draw_legend.R
@@ -30,7 +30,7 @@
 #' @param new_plot Logical. Should we be calling plot.new internally?
 #' @param draw Logical. If `FALSE`, no legend is drawn but the sizes are
 #'   returned. Note that a new (blank) plot frame will still need to be started
-#'   in order to perform the calculations. 
+#'   in order to perform the calculations.
 #' 
 #' @returns No return value, called for side effect of producing a(n empty) plot
 #'   with a legend in the margin.
diff --git a/R/draw_multi_legend.R b/R/draw_multi_legend.R
new file mode 100644
index 00000000..9938b027
--- /dev/null
+++ b/R/draw_multi_legend.R
@@ -0,0 +1,133 @@
+#' @title Draw multiple legends with automatic positioning
+#'   
+#' @description Internal function to draw multiple legends (e.g., bubble + color)
+#'   with automatic dimension calculation and positioning. This function handles
+#'   the internal gymnastics of required to determine the individual legend
+#'   dimensions, before drawing them in the optimal order and position.
+#'   
+#' @md
+#' @param legend_list A list of legend arguments, where each element is itself a
+#'   list of arguments that can be passed on to [draw_legend]. Legends will be
+#'   drawn vertically (top to bottom) in the order that they are provided. Note
+#'   that we currently only support dual legends, i.e. the top-level list has a
+#'   maximum length of 2.
+#' @param position String indicating the base keyword position for the
+#'   multi-legend. Currently only `"right!"` and `"left!"` are supported.
+#' 
+#' @returns No return value, called for side effect of drawing multiple legends.
+#' 
+#' @seealso [draw_legend]
+#' 
+#' @keywords internal
+#' 
+#' @examples
+#' \dontrun{
+#' oldmar = par("mar")
+#' 
+#' # Dual legend example (color + bubble)
+#' 
+#' l1 = list(
+#'   lgnd_labs = c("Red", "Blue", "Green"),
+#'   legend_args = list(title = "Colors"),
+#'   pch = 16,
+#'   col = c("red", "blue", "green"),
+#'   type = "p"
+#' )
+#' 
+#' l2 = list(
+#'   lgnd_labs = c("Tiny", "Small", "Medium", "Large", "Huge"),
+#'   legend_args = list(title = "Size"),
+#'   pch = 16,
+#'   col = "black",
+#'   cex = seq(0.5, 2.5, length.out = 5),
+#'   type = "p"
+#' )
+#' 
+#' # Draw together
+#' draw_multi_legend(list(l1, l2), position = "right!")
+#' 
+#' par(mar = oldmar)
+#' }
+#' 
+#' @keywords internal
+draw_multi_legend = function(
+    legend_list,
+    position = "right!"
+) {
+  
+  # Validate inputs
+  if (!is.list(legend_list) || length(legend_list) != 2) {
+    stop("Currently only 2 legends are supported in multi-legend mode")
+  }
+  
+  # Currently only support right!/left! positioning
+  if (!grepl("right!$|left!$", position)) {
+    warning(
+      '\nMulti-legends currently only work with "right!" or "left!" keyword positioning.\n',
+      'Reverting to "right!" default\n'
+    )
+    position = "right!"
+  }
+  
+  ## FIXME: current logic only works for "right!"/"left!" legend
+  # Determine sub-positions based on main position
+  if (grepl("right!$", position)) {
+    sub_positions = c("bottomright!", "topright!")
+  } else if (grepl("left!$", position)) {
+    sub_positions = c("bottomleft!", "topleft!")
+  }
+  
+  # Assign positions of individual legends
+  for (ll in seq_along(legend_list)) {
+    legend_list[[ll]][["legend"]] = sub_positions[ll]
+    legend_list[[ll]][["legend_args"]][["x"]] = NULL
+  }
+  
+  #
+  ## Step 1: Extract legend dimensions (by drawing fake legends)
+  #
+
+  legend_dims = vector("list", length(legend_list))
+  for (ll in seq_along(legend_list)) {
+    legend_ll = legend_list[[ll]]
+    legend_ll$new_plot = ll==1 ## only draw new plot for first legend
+    legend_ll$draw = FALSE
+    legend_dims[[ll]] = do.call(draw_legend, legend_ll)
+  }
+
+  #
+  ## Step 2: Calculate sub-positioning based on dimensions
+  #
+
+  # Extract dimensions
+  lwidths = sapply(legend_dims, function(x) x$rect$w)
+  lheights = sapply(legend_dims, function(x) x$rect$h)
+  # for inset adjustment, default to 0.5 unless one or more of the two legends
+  # is bigger than half the plot height.
+  linset = if (any(lheights > 0.5)) lheights[2] / sum(lheights) else 0.5
+
+  #
+  ## Step 3: Reposition (via adjusted an `inset` arg) and draw legends
+  #
+  
+  # Note: we draw the legends in ascending order of width (i.e., widest legend
+  #   last) in order to correctly set the overall plot dimensions.
+  width_order = order(lwidths)
+
+  # quick idx for original order (needed for vertical legend placement)
+  for (i in seq_along(legend_list)) legend_list[[i]]$idx = i
+
+  for (o in seq_along(width_order)) {
+    io = width_order[o]
+    legend_o = legend_list[[io]]
+    legend_o$new_plot = FALSE
+    legend_o$draw = TRUE
+    legend_o$legend_args$inset = c(0, 0)
+    legend_o$legend_args$inset[1] = if(o==1) -abs(diff(lwidths))/2 else 0
+    legend_o$legend_args$inset[2] = if (legend_o$idx==1) linset + 0.01 else 1 - linset + 0.01
+    legend_o$idx = NULL
+    do.call(draw_legend, legend_o)
+  }
+  
+  invisible(NULL)
+}
diff --git a/R/tinyplot.R b/R/tinyplot.R
index 0dffd072..d50ebdc2 100644
--- a/R/tinyplot.R
+++ b/R/tinyplot.R
@@ -1099,31 +1099,11 @@ tinyplot.default = function(
     } else {
       ## dual legend case...
 
-      ## FIXME: current logic only works for "right!"/"left!" legend
       legend_args = sanitize_legend(legend, legend_args)
-      legend_pos = legend_args[["x"]]
-      legend_args[["x"]] = NULL
-      if (!grepl("right!$|left!$", legend_pos)) {
-        warning(
-          '\nDual legends currently only work with "right!" or "left!" keyword positioning.\n',
-          'Reverting to "right!" default\n'
-        )
-        legend_pos = "right!"
-        lgby_pos = "bottomright!"
-        lgbub_pos = "topright!"
-      } else if (grepl("right!$", legend_pos)) {
-        lgby_pos = "bottomright!"
-        lgbub_pos = "topright!"
-      } else if (grepl("left!$", legend_pos)) {
-        lgby_pos = "bottomleft!"
-        lgbub_pos = "topleft!"
-      }
-
-      # first we get the respective legend rect dimensions (i.e., draw = FALSE)
 
-      # legend 1: by grouping
-      lgby = draw_legend(
-        legend = lgby_pos,
+      # legend 1: by (grouping) key
+      lgby = list(
+        # legend = lgby_pos,
         legend_args = modifyList(
           legend_args,
           list(x.intersp = 1, y.intersp = 1),
@@ -1138,14 +1118,13 @@ tinyplot.default = function(
         col = col,
         bg = bg,
         gradient = by_continuous,
-        cex = cex * cex_fct_adj,
-        # cex = lgnd_cex,
-        has_sub = has_sub,
-        draw = FALSE
+        # cex = cex * cex_fct_adj,
+        cex = lgnd_cex,
+        has_sub = has_sub
       )
-      # legend 2: bubble
-      lgbub = draw_legend(
-        legend = lgbub_pos,
+      # legend 2: bubble (size) key
+      lgbub = list(
+        # legend = lgbub_pos,
         legend_args = modifyList(
           legend_args,
           list(title = cex_dep, ncol = 1),
@@ -1157,134 +1136,17 @@ tinyplot.default = function(
         pch = pch,
         lty = lty,
         lwd = lwd,
-        col = "black",
-        # bg = bg,
-        gradient = by_continuous,
+        col = adjustcolor(par("col"), alpha.f = bubble_alpha),
+        bg = adjustcolor(par("col"), alpha.f = bubble_bg_alpha),
+        # gradient = by_continuous,
         cex = bubble_cex * cex_fct_adj,
         has_sub = has_sub,
-        draw = FALSE,
-        new_plot = FALSE # NB!
+        draw = FALSE
       )
 
-      # grab respective legend dimensions
-      lgby_w = lgby$rect$w
-      lgby_h = lgby$rect$h
-      lgbub_w = lgbub$rect$w
-      lgbub_h = lgbub$rect$h
-      # for inset adjustment, default to 0.5 unless one or more of the two
-      # legends is bigger than half the plot height.
-      lgby_inset = if (lgby_h > 0.5 || lgbub_h > 0.5) lgbub_h / (lgby_h + lgbub_h) else 0.5
-
-      # Finally, reposition (via adjusted an `inset` arg) and draw both legends
-      # Note: the drawing order depends on which legend is "wider", since this
-      #   helps to correctly set the overall plot dimensions.
-
-      if (lgby_w > lgbub_w) {
-        # case I: by legend is wider; draw bubble first
-        ## first, draw bubble legend
-        draw_legend(
-          legend = lgbub_pos,
-          legend_args = modifyList(
-            legend_args,
-            list(
-              title = cex_dep,
-              ncol = 1,
-              inset = c((lgbub_w-lgby_w)/2, 1 - lgby_inset + 0.01)
-            ),
-            keep.null = TRUE
-          ),
-          lgnd_labs = names(bubble_cex),
-          type = type,
-          pch = bubble_pch,
-          lty = lty,
-          lwd = lwd,
-          col = adjustcolor(par("col"), alpha.f = bubble_alpha),
-          bg = adjustcolor(par("col"), alpha.f = bubble_bg_alpha),
-          cex = bubble_cex * cex_fct_adj,
-          has_sub = has_sub,
-          new_plot = FALSE # NB!
-        )
-        ## next, draw by legend (with new_plot = FALSE)
-        draw_legend(
-          legend = lgby_pos,
-          legend_args = modifyList(
-            legend_args,
-            list(
-              inset = c(0, lgby_inset + 0.01),
-              x.intersp = 1, y.intersp = 1
-            ),
-            keep.null = TRUE
-          ),
-          by_dep = by_dep,
-          lgnd_labs = lgnd_labs,
-          type = type,
-          pch = pch,
-          lty = lty,
-          lwd = lwd,
-          col = col,
-          bg = bg,
-          gradient = by_continuous,
-          # cex = cex * cex_fct_adj,
-          cex = lgnd_cex,
-          has_sub = has_sub,
-          new_plot = FALSE # NB!
-        )
+      # draw dual legend
+      draw_multi_legend(list(lgby, lgbub), position = legend_args[["x"]])
 
-      } else {
-        # browser()
-        # Case II: bubble legend is wider; draw by legend first
-        ## first, draw by legend
-        draw_legend(
-          legend = lgby_pos,
-          legend_args = modifyList(
-            legend_args,
-            list(
-              inset = c((lgby_w-lgbub_w)/2, lgby_inset + 0.01),
-              x.intersp = 1, y.intersp = 1
-            ),
-            keep.null = TRUE
-          ),
-          by_dep = by_dep,
-          lgnd_labs = lgnd_labs,
-          type = type,
-          pch = pch,
-          lty = lty,
-          lwd = lwd,
-          col = col,
-          bg = bg,
-          gradient = by_continuous,
-          # cex = cex * cex_fct_adj,
-          cex = lgnd_cex,
-          has_sub = has_sub,
-          new_plot = FALSE # NB!
-        )
-        ## next, draw bubble legend (with plot_new = FALSE)
-        draw_legend(
-          legend = lgbub_pos,
-          legend_args = modifyList(
-            legend_args,
-            list(
-              title = cex_dep,
-              ncol = 1,
-              inset = c(0, 1 - lgby_inset + 0.01)
-            ),
-            keep.null = TRUE
-          ),
-          lgnd_labs = names(bubble_cex),
-          type = type,
-          pch = bubble_pch,
-          lty = lty,
-          lwd = lwd,
-          col = adjustcolor(par("col"), alpha.f = bubble_alpha),
-          bg = adjustcolor(par("col"), alpha.f = bubble_bg_alpha),
-          # gradient = by_continuous,
-          cex = bubble_cex * cex_fct_adj,
-          has_sub = has_sub,
-          new_plot = FALSE # NB!
-        )
-        
-      } ## end dual legend case
-      
     }
 
     has_legend = TRUE
diff --git a/man/draw_multi_legend.Rd b/man/draw_multi_legend.Rd
new file mode 100644
index 00000000..617512a5
--- /dev/null
+++ b/man/draw_multi_legend.Rd
@@ -0,0 +1,61 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/draw_multi_legend.R
+\name{draw_multi_legend}
+\alias{draw_multi_legend}
+\title{Draw multiple legends with automatic positioning}
+\usage{
+draw_multi_legend(legend_list, position = "right!")
+}
+\arguments{
+\item{legend_list}{A list of legend arguments, where each element is itself a
+list of arguments that can be passed on to \link{draw_legend}. Legends will be
+drawn vertically (top to bottom) in the order that they are provided. Note
+that we currently only support dual legends, i.e. the top-level list has a
+maximum length of 2.}
+
+\item{position}{String indicating the base keyword position for the
+multi-legend. Currently only \code{"right!"} and \code{"left!"} are supported.}
+}
+\value{
+No return value, called for side effect of drawing multiple legends.
+}
+\description{
+Internal function to draw multiple legends (e.g., bubble + color)
+with automatic dimension calculation and positioning. This function handles
+the internal gymnastics of required to determine the individual legend
+dimensions, before drawing them in the optimal order and position.
+}
+\examples{
+\dontrun{
+oldmar = par("mar")
+
+# Dual legend example (color + bubble)
+
+l1 = list(
+  lgnd_labs = c("Red", "Blue", "Green"),
+  legend_args = list(title = "Colors"),
+  pch = 16,
+  col = c("red", "blue", "green"),
+  type = "p"
+)
+
+l2 = list(
+  lgnd_labs = c("Tiny", "Small", "Medium", "Large", "Huge"),
+  legend_args = list(title = "Size"),
+  pch = 16,
+  col = "black",
+  cex = seq(0.5, 2.5, length.out = 5),
+  type = "p"
+)
+
+# Draw together
+draw_multi_legend(list(l1, l2), position = "right!")
+
+par(mar = oldmar)
+}
+
+}
+\seealso{
+\link{draw_legend}
+}
+\keyword{internal}

From 2c24ac93da3628d86a4908fd8947a64c93e2110d Mon Sep 17 00:00:00 2001
From: Grant McDermott <gmcd@amazon.com>
Date: Fri, 5 Sep 2025 12:15:14 -0700
Subject: [PATCH 28/29] typo

---
 R/draw_multi_legend.R    | 2 +-
 man/draw_multi_legend.Rd | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/R/draw_multi_legend.R b/R/draw_multi_legend.R
index 9938b027..6575fb63 100644
--- a/R/draw_multi_legend.R
+++ b/R/draw_multi_legend.R
@@ -2,7 +2,7 @@
 #'   
 #' @description Internal function to draw multiple legends (e.g., bubble + color)
 #'   with automatic dimension calculation and positioning. This function handles
-#'   the internal gymnastics of required to determine the individual legend
+#'   the internal gymnastics required to determine the individual legend
 #'   dimensions, before drawing them in the optimal order and position.
 #'   
 #' @md
diff --git a/man/draw_multi_legend.Rd b/man/draw_multi_legend.Rd
index 617512a5..d754a35a 100644
--- a/man/draw_multi_legend.Rd
+++ b/man/draw_multi_legend.Rd
@@ -22,7 +22,7 @@ No return value, called for side effect of drawing multiple legends.
 \description{
 Internal function to draw multiple legends (e.g., bubble + color)
 with automatic dimension calculation and positioning. This function handles
-the internal gymnastics of required to determine the individual legend
+the internal gymnastics required to determine the individual legend
 dimensions, before drawing them in the optimal order and position.
 }
 \examples{

From 50ddf0a0710febe0dffabdffc08a9783b64953e3 Mon Sep 17 00:00:00 2001
From: Grant McDermott <gmcd@amazon.com>
Date: Fri, 5 Sep 2025 16:05:32 -0700
Subject: [PATCH 29/29] minor clean up

---
 R/draw_legend.R | 7 -------
 1 file changed, 7 deletions(-)

diff --git a/R/draw_legend.R b/R/draw_legend.R
index 28fe1a2f..1f5aba88 100644
--- a/R/draw_legend.R
+++ b/R/draw_legend.R
@@ -436,13 +436,6 @@ tinylegend = function(
     if (outer_end) fklgnd.args = modifyList(fklgnd.args, list(title = NULL), keep.null = TRUE)
   }
   
-  # if (draw) {
-  #   fklgnd = do.call("legend", fklgnd.args)
-  # } else {
-  #   # plot.new()
-  #   fklgnd = do.call("legend", fklgnd.args)
-  #   return(fklgnd)
-  # }
   fklgnd = do.call("legend", fklgnd.args)
   if (!draw) return(fklgnd)
   
