diff --git a/Makefile b/Makefile index 0a9043b..3dad0fc 100644 --- a/Makefile +++ b/Makefile @@ -1,43 +1,107 @@ -.PHONY: format format-r document document-r install install-r test test-r +R_DIR := r +PKG := $(shell awk '/^Package:/{print $$2; exit}' $(R_DIR)/DESCRIPTION) +R ?= R +RSCRIPT ?= Rscript -# ── Format ─────────────────────────────────────────────────────────────────── +msg = @printf '\033[38;2;108;163;160m[%s] %s\033[0m\n' "$$(date -u '+%Y-%m-%d %H:%M:%SZ')" "$(1)" + +.DEFAULT_GOAL := help + +.PHONY: help format format-r document document-r install install-r test test-r \ + build build-r check check-r check-cran manual-r site site-r clean clean-r + +# ── Help ───────────────────────────────────────────────────────────────────── +help: + $(call msg,Available targets:) + @printf '%s\n' \ + ' format Format R code with air CLI (if available)' \ + ' document Generate roxygen2 documentation' \ + ' install Document and install the R package with pak' \ + ' test Run testthat::test_local(stop_on_failure = TRUE)' \ + ' build Build the R source tarball' \ + ' check Run R CMD check on the built tarball' \ + ' check-cran Run R CMD check --as-cran' \ + ' site Build pkgdown site' \ + ' clean Remove tarballs and .Rcheck output' + +# ── Format ──────────────────────────────────────────────────────────────────── format: format-r format-r: - @echo "==> R: Formatting rtemis.draw" - cd r && air format . + $(call msg,─── Formatting $(PKG) [R]... ───) + @if command -v air >/dev/null 2>&1; then \ + cd $(R_DIR) && air format .; \ + else \ + echo " Note: 'air' CLI not found — skipping R code formatting."; \ + fi + $(call msg,Done) -# ── Document ───────────────────────────────────────────────────────────────── +# ── Document ────────────────────────────────────────────────────────────────── document: document-r document-r: format-r - @echo "=> R: Documenting rtemis.draw" - cd r && Rscript -e "devtools::document()" + $(call msg,─── Documenting $(PKG) [R]... ───) + cd $(R_DIR) && $(RSCRIPT) -e "roxygen2::roxygenize()" + $(call msg,Done) -# ── Document & Install ─────────────────────────────────────────────────────── +# ── Install ─────────────────────────────────────────────────────────────────── install: install-r install-r: document-r - @echo "==> R: Installing rtemis.draw" - cd r && Rscript -e "devtools::install()" + $(call msg,─── Installing $(PKG) [R]... ───) + cd $(R_DIR) && $(RSCRIPT) -e "pak::local_install(upgrade = TRUE)" + $(call msg,Done) -# ── Test ───────────────────────────────────────────────────────────────────── +# ── Test ────────────────────────────────────────────────────────────────────── test: test-r test-r: - @echo "==> R: Testing rtemis.draw" - cd r && Rscript -e "devtools::test(stop_on_failure = TRUE)" + $(call msg,─── Testing $(PKG) [R]... ───) + cd $(R_DIR) && $(RSCRIPT) -e "testthat::test_local(stop_on_failure = TRUE)" + $(call msg,Done) + +# ── Build ───────────────────────────────────────────────────────────────────── +build: build-r -# ── Check ──────────────────────────────────────────────────────────────────── +build-r: clean-r + $(call msg,─── Building $(PKG) [R]... ───) + cd $(R_DIR) && $(R) CMD build . + $(call msg,Done) + +# ── Check ───────────────────────────────────────────────────────────────────── check: check-r -check-r: - @echo "==> R: Checking rtemis.draw" - cd r && Rscript -e "devtools::check()" +check-r: build-r + $(call msg,─── Running R CMD check on $(PKG) [R]... ───) + cd $(R_DIR) && $(R) CMD check $(PKG)_*.tar.gz + rm -f $(R_DIR)/$(PKG)_*.tar.gz + $(call msg,Done) + +check-cran: build-r + $(call msg,─── Running R CMD check --as-cran on $(PKG) [R]... ───) + cd $(R_DIR) && $(R) CMD check $(PKG)_*.tar.gz --as-cran + rm -f $(R_DIR)/$(PKG)_*.tar.gz + $(call msg,Done) + +# ── Manual ─────────────────────────────────────────────────────────────────── +manual-r: + $(call msg,─── Building manual for $(PKG)... ───) + cd $(R_DIR) && $(R) CMD Rd2pdf . --output=$(PKG).pdf + $(call msg,Done) -# ── Build Site ─────────────────────────────────────────────────────────────── +# ── Site ────────────────────────────────────────────────────────────────────── site: site-r site-r: - @echo "==> R: Building pkgdown site for rtemis.draw" - cd r && Rscript -e "pkgdown::build_site()" + $(call msg,─── Building pkgdown site for $(PKG) [R]... ───) + cd $(R_DIR) && $(RSCRIPT) -e "pkgdown::build_site()" + $(call msg,Done) + +# ── Clean ───────────────────────────────────────────────────────────────────── +clean: clean-r + +clean-r: + $(call msg,─── Cleaning build artifacts [R]... ───) + rm -rf $(R_DIR)/$(PKG).Rcheck + rm -f $(R_DIR)/$(PKG)_*.tar.gz + $(call msg,Done) diff --git a/r/DESCRIPTION b/r/DESCRIPTION index 5b72acb..0cf4cba 100644 --- a/r/DESCRIPTION +++ b/r/DESCRIPTION @@ -1,6 +1,6 @@ Package: rtemis.draw Title: Interactive Visualization Using ECharts -Version: 0.1.0 +Version: 0.2.1 Authors@R: person(given = "E.D.", family = "Gennatas", role = c("aut", "cre", "cph"), email = "gennatas@gmail.com", @@ -15,12 +15,11 @@ URL: https://www.rtemis.org, https://docs.rtemis.org/draw/r, https://docs.rtemis License: GPL (>= 3) Encoding: UTF-8 Roxygen: list(markdown = TRUE) -RoxygenNote: 7.3.3 Imports: cli, htmlwidgets, jsonlite, - rtemis.core, + rtemis.core (>= 0.1.0), S7, signal, viridisLite @@ -36,6 +35,7 @@ VignetteBuilder: knitr Config/testthat/edition: 3 Config/testthat/parallel: true Collate: + '00_init.R' 'rtemis.draw-package.R' 'rtemis_color_system.R' 'utils.R' @@ -44,6 +44,7 @@ Collate: 'axis.R' 'components.R' 'series.R' + 'series_sankey.R' 'option.R' 'theme.R' 'draw.R' @@ -51,3 +52,4 @@ Collate: 'draw_spectrogram.R' 'export.R' 'zzz.R' +Config/roxygen2/version: 8.0.0 diff --git a/r/NAMESPACE b/r/NAMESPACE index 5be499f..6d08a74 100644 --- a/r/NAMESPACE +++ b/r/NAMESPACE @@ -22,6 +22,10 @@ export(MarkAreaDataPoint) export(MinorSplitLine) export(MinorTick) export(PieSeries) +export(SankeyEdgeItem) +export(SankeyLevelOption) +export(SankeyNodeItem) +export(SankeySeries) export(ScatterSeries) export(SplitArea) export(SplitLine) @@ -40,6 +44,7 @@ export(draw_heatmap) export(draw_histogram) export(draw_line) export(draw_pie) +export(draw_sankey) export(draw_scatter) export(draw_spectrogram) export(renderDraw) diff --git a/r/R/00_init.R b/r/R/00_init.R new file mode 100644 index 0000000..c51a0b6 --- /dev/null +++ b/r/R/00_init.R @@ -0,0 +1,28 @@ +# 00_init.R +# Local property definitions that mirror upstream rtemis.core exports not yet +# on CRAN. Remove each entry here once the corresponding rtemis.core version +# is available on CRAN and declared in DESCRIPTION. + +# nonneg_integer_scalar / optional_nonneg_integer_scalar +# Added to rtemis.core >= 0.1.1; mirrors 00_S7_properties.R. +nonneg_integer_scalar <- S7::new_property( + S7::class_integer, + validator = function(value) { + if (length(value) != 1L || is.na(value) || value < 0L) { + return("must be a non-negative integer scalar (>= 0, e.g. 0L)") + } + NULL + } +) + +optional_nonneg_integer_scalar <- S7::new_property( + class = S7::new_union(NULL, S7::class_integer), + validator = function(value) { + if ( + !is.null(value) && (length(value) != 1L || is.na(value) || value < 0L) + ) { + return("must be NULL or a non-negative integer scalar (>= 0, e.g. 0L)") + } + NULL + } +) diff --git a/r/R/axis.R b/r/R/axis.R index e4383e6..5d434fe 100644 --- a/r/R/axis.R +++ b/r/R/axis.R @@ -41,7 +41,7 @@ AxisLine <- S7::new_class( "must be TRUE, FALSE, 'auto', or NULL" } ), - on_zero = bool_or_null_property(), + on_zero = optional_logical_scalar, on_zero_axis_index = numeric_or_null_property(), symbol = S7::new_property( class = S7::class_any, @@ -124,9 +124,9 @@ AxisTick <- S7::new_class( "must be TRUE, FALSE, 'auto', or NULL" } ), - inside = bool_or_null_property(), + inside = optional_logical_scalar, length = numeric_or_null_property(), - align_with_label = bool_or_null_property(), + align_with_label = optional_logical_scalar, line_style = class_or_null_property(LineStyle) ) ) @@ -152,7 +152,7 @@ S7::method(to_list, AxisTick) <- function(x, ...) { MinorTick <- S7::new_class( "MinorTick", properties = list( - show = bool_or_null_property(), + show = optional_logical_scalar, split_number = numeric_or_null_property(), length = numeric_or_null_property(), line_style = class_or_null_property(LineStyle) @@ -181,7 +181,7 @@ S7::method(to_list, MinorTick) <- function(x, ...) { SplitLine <- S7::new_class( "SplitLine", properties = list( - show = bool_or_null_property(), + show = optional_logical_scalar, interval = S7::new_property( class = S7::class_any, default = NULL, @@ -198,8 +198,8 @@ SplitLine <- S7::new_class( "must be a number, 'auto', or NULL" } ), - show_min_line = bool_or_null_property(), - show_max_line = bool_or_null_property(), + show_min_line = optional_logical_scalar, + show_max_line = optional_logical_scalar, line_style = class_or_null_property(LineStyle) ) ) @@ -223,7 +223,7 @@ S7::method(to_list, SplitLine) <- function(x, ...) { MinorSplitLine <- S7::new_class( "MinorSplitLine", properties = list( - show = bool_or_null_property(), + show = optional_logical_scalar, line_style = class_or_null_property(LineStyle) ) ) @@ -248,7 +248,7 @@ S7::method(to_list, MinorSplitLine) <- function(x, ...) { SplitArea <- S7::new_class( "SplitArea", properties = list( - show = bool_or_null_property(), + show = optional_logical_scalar, interval = S7::new_property( class = S7::class_any, default = NULL, @@ -297,14 +297,14 @@ S7::method(to_list, SplitArea) <- function(x, ...) { AxisLabel <- S7::new_class( "AxisLabel", properties = list( - show = bool_or_null_property(), - inside = bool_or_null_property(), + show = optional_logical_scalar, + inside = optional_logical_scalar, rotate = numeric_or_null_property(), margin = numeric_or_null_property(), formatter = S7::new_property(class = S7::class_any, default = NULL), - show_min_label = bool_or_null_property(), - show_max_label = bool_or_null_property(), - hide_overlap = bool_or_null_property(), + show_min_label = optional_logical_scalar, + show_max_label = optional_logical_scalar, + hide_overlap = optional_logical_scalar, interval = S7::new_property( class = S7::class_any, default = NULL, @@ -386,16 +386,19 @@ Axis <- S7::new_class( "Axis", properties = list( # AxisBaseOptionCommon - type = enum_property(c("value", "category", "time", "log")), - show = bool_or_null_property(), - inverse = bool_or_null_property(), - name = string_or_null_property(), - name_location = enum_property(c("start", "middle", "center", "end")), + type = enum(c("value", "category", "time", "log"), nullable = TRUE), + show = optional_logical_scalar, + inverse = optional_logical_scalar, + name = optional_character_scalar, + name_location = enum( + c("start", "middle", "center", "end"), + nullable = TRUE + ), name_rotate = numeric_or_null_property(), name_gap = numeric_or_null_property(), name_text_style = class_or_null_property(TextStyle), - silent = bool_or_null_property(), - trigger_event = bool_or_null_property(), + silent = optional_logical_scalar, + trigger_event = optional_logical_scalar, # Min/max: number, string ("dataMin"/"dataMax"), or NULL min = S7::new_property( class = S7::class_any, @@ -438,13 +441,13 @@ Axis <- S7::new_class( } ), # ValueAxisBaseOption - scale = bool_or_null_property(), + scale = optional_logical_scalar, # NumericAxisBaseOptionCommon split_number = numeric_or_null_property(), interval = numeric_or_null_property(), min_interval = numeric_or_null_property(), max_interval = numeric_or_null_property(), - align_ticks = bool_or_null_property(), + align_ticks = optional_logical_scalar, # LogAxisBaseOption log_base = numeric_or_null_property(), # CategoryAxisBaseOption: boundaryGap is boolean @@ -476,7 +479,7 @@ Axis <- S7::new_class( minor_split_line = class_or_null_property(MinorSplitLine), split_area = class_or_null_property(SplitArea), # Axis position: "left"/"right" for yAxis, "top"/"bottom" for xAxis - position = enum_property(c("left", "right", "top", "bottom")), + position = enum(c("left", "right", "top", "bottom"), nullable = TRUE), # Multi-grid support: index of the grid this axis belongs to grid_index = numeric_or_null_property() ) diff --git a/r/R/components.R b/r/R/components.R index 1591458..696ce91 100644 --- a/r/R/components.R +++ b/r/R/components.R @@ -37,7 +37,7 @@ Grid <- S7::new_class( "Grid", properties = list( - show = bool_or_null_property(), + show = optional_logical_scalar, # BoxLayoutOptionMixin left = numeric_or_string_property(), right = numeric_or_string_property(), @@ -45,13 +45,13 @@ Grid <- S7::new_class( bottom = numeric_or_string_property(), width = numeric_or_string_property(), height = numeric_or_string_property(), - contain_label = bool_or_null_property(), - background_color = color_property(), + contain_label = optional_logical_scalar, + background_color = optional_character_scalar, border_width = numeric_or_null_property(), - border_color = color_property(), + border_color = optional_character_scalar, # ShadowOptionMixin shadow_blur = numeric_or_null_property(), - shadow_color = color_property(), + shadow_color = optional_character_scalar, shadow_offset_x = numeric_or_null_property(), shadow_offset_y = numeric_or_null_property() ) @@ -96,15 +96,18 @@ S7::method(to_list, Grid) <- function(x, ...) { Title <- S7::new_class( "Title", properties = list( - show = bool_or_null_property(), - text = string_or_null_property(), - link = string_or_null_property(), - target = enum_property(c("self", "blank")), - subtext = string_or_null_property(), - sublink = string_or_null_property(), - subtarget = enum_property(c("self", "blank")), - text_align = enum_property(c("auto", "left", "center", "right")), - text_vertical_align = enum_property(c("auto", "top", "middle", "bottom")), + show = optional_logical_scalar, + text = optional_character_scalar, + link = optional_character_scalar, + target = enum(c("self", "blank"), nullable = TRUE), + subtext = optional_character_scalar, + sublink = optional_character_scalar, + subtarget = enum(c("self", "blank"), nullable = TRUE), + text_align = enum(c("auto", "left", "center", "right"), nullable = TRUE), + text_vertical_align = enum( + c("auto", "top", "middle", "bottom"), + nullable = TRUE + ), padding = S7::new_property( class = S7::class_any, default = NULL, @@ -121,9 +124,9 @@ Title <- S7::new_class( item_gap = numeric_or_null_property(), text_style = class_or_null_property(TextStyle), subtext_style = class_or_null_property(TextStyle), - trigger_event = bool_or_null_property(), - background_color = color_property(), - border_color = color_property(), + trigger_event = optional_logical_scalar, + background_color = optional_character_scalar, + border_color = optional_character_scalar, border_width = numeric_or_null_property(), border_radius = S7::new_property( class = S7::class_any, @@ -190,9 +193,9 @@ S7::method(to_list, Title) <- function(x, ...) { Legend <- S7::new_class( "Legend", properties = list( - show = bool_or_null_property(), - orient = enum_property(c("horizontal", "vertical")), - align = enum_property(c("auto", "left", "right")), + show = optional_logical_scalar, + orient = enum(c("horizontal", "vertical"), nullable = TRUE), + align = enum(c("auto", "left", "right"), nullable = TRUE), # BoxLayoutOptionMixin left = numeric_or_string_property(), right = numeric_or_string_property(), @@ -216,7 +219,7 @@ Legend <- S7::new_class( item_gap = numeric_or_null_property(), item_width = numeric_or_null_property(), item_height = numeric_or_null_property(), - icon = string_or_null_property(), + icon = optional_character_scalar, selected_mode = S7::new_property( class = S7::class_any, default = NULL, @@ -251,12 +254,12 @@ Legend <- S7::new_class( } ), formatter = S7::new_property(class = S7::class_any, default = NULL), - inactive_color = color_property(), - inactive_border_color = color_property(), + inactive_color = optional_character_scalar, + inactive_border_color = optional_character_scalar, text_style = class_or_null_property(TextStyle), item_style = class_or_null_property(ItemStyle), - background_color = color_property(), - border_color = color_property(), + background_color = optional_character_scalar, + border_color = optional_character_scalar, border_width = numeric_or_null_property(), border_radius = S7::new_property( class = S7::class_any, @@ -271,7 +274,7 @@ Legend <- S7::new_class( "must be a number or length-4 numeric vector, or NULL" } ), - trigger_event = bool_or_null_property(), + trigger_event = optional_logical_scalar, data = S7::new_property(class = S7::class_any, default = NULL) ) ) @@ -318,26 +321,29 @@ Tooltip <- S7::new_class( "Tooltip", properties = list( # CommonTooltipOption - show = bool_or_null_property(), - trigger = enum_property(c("item", "axis", "none")), - trigger_on = enum_property(c( - "mousemove", - "click", - "none", - "mousemove|click" - )), - show_content = bool_or_null_property(), - always_show_content = bool_or_null_property(), + show = optional_logical_scalar, + trigger = enum(c("item", "axis", "none"), nullable = TRUE), + trigger_on = enum( + c( + "mousemove", + "click", + "none", + "mousemove|click" + ), + nullable = TRUE + ), + show_content = optional_logical_scalar, + always_show_content = optional_logical_scalar, formatter = S7::new_property(class = S7::class_any, default = NULL), value_formatter = S7::new_property(class = S7::class_any, default = NULL), position = S7::new_property(class = S7::class_any, default = NULL), - confine = bool_or_null_property(), - enterable = bool_or_null_property(), + confine = optional_logical_scalar, + enterable = optional_logical_scalar, show_delay = numeric_or_null_property(), hide_delay = numeric_or_null_property(), transition_duration = numeric_or_null_property(), - background_color = color_property(), - border_color = color_property(), + background_color = optional_character_scalar, + border_color = optional_character_scalar, border_width = numeric_or_null_property(), border_radius = numeric_or_null_property(), padding = S7::new_property( @@ -354,15 +360,18 @@ Tooltip <- S7::new_class( } ), text_style = class_or_null_property(TextStyle), - extra_css_text = string_or_null_property(), + extra_css_text = optional_character_scalar, # TooltipOption-specific - order = enum_property(c( - "seriesAsc", - "seriesDesc", - "valueAsc", - "valueDesc" - )), - class_name = string_or_null_property() + order = enum( + c( + "seriesAsc", + "seriesDesc", + "valueAsc", + "valueDesc" + ), + nullable = TRUE + ), + class_name = optional_character_scalar ) ) @@ -404,13 +413,17 @@ S7::method(to_list, Tooltip) <- function(x, ...) { VisualMap <- S7::new_class( "VisualMap", properties = list( - type = enum_property(c("continuous", "piecewise"), default = "continuous"), + type = enum( + c("continuous", "piecewise"), + default = "continuous", + nullable = TRUE + ), min = numeric_or_null_property(), max = numeric_or_null_property(), precision = numeric_or_null_property(), - calculable = bool_or_null_property(), - show = bool_or_null_property(), - orient = enum_property(c("vertical", "horizontal")), + calculable = optional_logical_scalar, + show = optional_logical_scalar, + orient = enum(c("vertical", "horizontal"), nullable = TRUE), in_range = S7::new_property(class = S7::class_any, default = NULL), out_of_range = S7::new_property(class = S7::class_any, default = NULL), # BoxLayoutOptionMixin @@ -562,20 +575,23 @@ DataZoom <- S7::new_class( "DataZoom", properties = list( # Common (DataZoomOption) - type = enum_property(c("slider", "inside"), default = "slider"), - id = string_or_null_property(), - disabled = bool_or_null_property(), + type = enum(c("slider", "inside"), default = "slider", nullable = TRUE), + id = optional_character_scalar, + disabled = optional_logical_scalar, x_axis_index = axis_index_property(), y_axis_index = axis_index_property(), radius_axis_index = axis_index_property(), angle_axis_index = axis_index_property(), single_axis_index = axis_index_property(), - filter_mode = enum_property(c( - "filter", - "weakFilter", - "empty", - "none" - )), + filter_mode = enum( + c( + "filter", + "weakFilter", + "empty", + "none" + ), + nullable = TRUE + ), start = numeric_or_null_property(), end = numeric_or_null_property(), start_value = S7::new_property(class = S7::class_any, default = NULL), @@ -584,7 +600,7 @@ DataZoom <- S7::new_class( max_span = numeric_or_null_property(), min_value_span = numeric_or_string_property(), max_value_span = numeric_or_string_property(), - orient = enum_property(c("horizontal", "vertical")), + orient = enum(c("horizontal", "vertical"), nullable = TRUE), throttle = numeric_or_null_property(), range_mode = S7::new_property( class = S7::class_any, @@ -604,20 +620,20 @@ DataZoom <- S7::new_class( } ), # SliderDataZoomOption-specific - show = bool_or_null_property(), - background_color = color_property(), + show = optional_logical_scalar, + background_color = optional_character_scalar, left = numeric_or_string_property(), right = numeric_or_string_property(), top = numeric_or_string_property(), bottom = numeric_or_string_property(), width = numeric_or_string_property(), height = numeric_or_string_property(), - zoom_lock = bool_or_null_property(), + zoom_lock = optional_logical_scalar, # InsideDataZoomOption-specific zoom_on_mouse_wheel = mouse_modifier_property(), move_on_mouse_move = mouse_modifier_property(), move_on_mouse_wheel = mouse_modifier_property(), - prevent_default_mouse_move = bool_or_null_property() + prevent_default_mouse_move = optional_logical_scalar ) ) diff --git a/r/R/draw.R b/r/R/draw.R index c3b4299..cfdb174 100644 --- a/r/R/draw.R +++ b/r/R/draw.R @@ -313,9 +313,7 @@ draw_line <- function( } } - if (!is.logical(points) || length(points) != 1L || is.na(points)) { - cli::cli_abort("{.arg points} must be a single non-missing logical value.") - } + check_logical_scalar(points) # ECharts treats missing `showSymbol` as TRUE; only pass FALSE when hiding. show_symbol <- if (points) NULL else FALSE @@ -520,15 +518,7 @@ build_block_mark_area <- function(x, blocks, block_color, block_opacity) { "{.arg block_color} must be provided when {.arg blocks} is set." ) } - if ( - !is.numeric(block_opacity) || - length(block_opacity) != 1L || - !is.finite(block_opacity) || - block_opacity < 0 || - block_opacity > 1 - ) { - cli::cli_abort("{.arg block_opacity} must be a single number in [0, 1].") - } + check_prob_scalar(block_opacity) # Determine level order so positional matching is stable. For factors, drop # unused levels so `block_color` doesn't need entries for absent categories. @@ -848,9 +838,7 @@ draw_scatter <- function( yv <- yv[ok] df <- data.frame(.x = xv, .y = yv) if (fit_method == "gam") { - if (!requireNamespace("mgcv", quietly = TRUE)) { - stop("Package 'mgcv' is required for fit = \"gam\"", call. = FALSE) - } + check_dependencies("mgcv") model <- mgcv::gam(.y ~ s(.x), data = df) } else { model <- stats::glm(.y ~ .x, data = df) @@ -2514,3 +2502,94 @@ draw_heatmap <- function( meta = heatmap_meta ) } + +# -- draw_sankey ---------------------------------------------------------------- + +#' Draw a Sankey Diagram +#' +#' Sankey diagram from a data frame of directed links. Node names are derived +#' automatically from the unique values in the `source` and `target` columns; +#' no separate node list is required. +#' +#' @param links data.frame: Directed links with columns `source` (Character), +#' `target` (Character), and `value` (Numeric). +#' @param orient Optional Character \{"horizontal", "vertical"\}: Flow direction. +#' @param node_width Optional Numeric `[0, Inf)`: Node rectangle width in pixels. +#' @param node_gap Optional Numeric `[0, Inf)`: Vertical gap between nodes in pixels. +#' @param node_align Optional Character \{"justify", "left", "right"\}: Node +#' alignment within each column. +#' @param title Optional Character: Chart title. +#' @param color Optional Character: Node color palette — a single color string or +#' a character vector that overrides the theme palette for this chart. +#' @param theme Optional [Theme]: Theme override. +#' @param width Optional Character or Numeric: Widget width. +#' @param height Optional Character or Numeric: Widget height. +#' @param filename Optional Character: If provided, save the widget to this file +#' via [save_drawing()]. +#' @return htmlwidget: Widget object. +#' @examples +#' links <- data.frame( +#' source = c("A", "A", "B", "C"), +#' target = c("B", "C", "D", "D"), +#' value = c(8, 4, 6, 3) +#' ) +#' draw_sankey(links) +#' @export +draw_sankey <- function( + links, + orient = "horizontal", + node_width = NULL, + node_gap = NULL, + node_align = NULL, + title = NULL, + color = NULL, + theme = NULL, + width = NULL, + height = NULL, + filename = NULL +) { + rtemis.core::check_tabular(links) + required_cols <- c("source", "target", "value") + missing_cols <- setdiff(required_cols, names(links)) + if (length(missing_cols) > 0L) { + cli::cli_abort( + "{.arg links} must have columns {.val {required_cols}}; missing: {.val {missing_cols}}." + ) + } + + # Coerce to character: factor columns with different levels produce an + # integer vector when concatenated with c(). + node_names <- unique( + c(as.character(links[["source"]]), as.character(links[["target"]])) + ) + nodes <- lapply(node_names, function(n) list(name = n)) + + # Convert links data.frame rows to a list of named lists + edge_list <- lapply(seq_len(nrow(links)), function(i) { + list( + source = as.character(links[["source"]][[i]]), + target = as.character(links[["target"]][[i]]), + value = links[["value"]][[i]] + ) + }) + + # ECharts ignores a palette longer than the node count; trim (or cycle) to + # exactly one entry per node. unname() prevents named vectors from + # serializing as a JSON object instead of an array. + palette <- unname(rep_len(color %||% rtemis_colors, length(node_names))) + opt <- EChartsOption( + title = if (!is.null(title)) Title(text = title, left = "center") else NULL, + tooltip = Tooltip(trigger = "item"), + color = palette, + series = SankeySeries( + data = nodes, + links = edge_list, + orient = orient, + node_width = node_width, + node_gap = node_gap, + node_align = node_align + ) + ) + + draw(opt, theme = theme, width = width, height = height, filename = filename) +} diff --git a/r/R/draw_spectrogram.R b/r/R/draw_spectrogram.R index 9520c4a..bc0de47 100644 --- a/r/R/draw_spectrogram.R +++ b/r/R/draw_spectrogram.R @@ -246,7 +246,7 @@ draw_spectrogram <- function( xlab = NULL, ylab = NULL, theme = NULL, - margins = DEFAULT_MARGINS, + margins = NULL, width = NULL, height = NULL, filename = NULL @@ -330,6 +330,15 @@ draw_spectrogram <- function( validate_range(time_range, "time_range") validate_range(zlim, "zlim") + # Auto margins if title is set + if (is.null(margins)) { + margins <- DEFAULT_MARGINS + margins[["right"]] <- "90" + if (!is.null(title)) { + margins[["top"]] <- "50" + } + } + # -- 2. STFT (raw signal) or matrix input ------------------------------------ if (is_raw_signal) { if (is.null(sample_rate)) { diff --git a/r/R/label.R b/r/R/label.R index 92851af..eed5022 100644 --- a/r/R/label.R +++ b/r/R/label.R @@ -35,7 +35,7 @@ LabelOption <- S7::new_class( "LabelOption", properties = list( - show = bool_or_null_property(), + show = optional_logical_scalar, position = S7::new_property( class = S7::class_any, default = NULL, @@ -89,7 +89,7 @@ LabelOption <- S7::new_class( } ), formatter = S7::new_property(class = S7::class_any, default = NULL), - silent = bool_or_null_property(), + silent = optional_logical_scalar, precision = S7::new_property( class = S7::class_any, default = NULL, @@ -106,7 +106,7 @@ LabelOption <- S7::new_class( "must be a number, 'auto', or NULL" } ), - value_animation = bool_or_null_property(), + value_animation = optional_logical_scalar, min_margin = numeric_or_null_property(), # Text styling -- flattened into the same JSON object in to_list() text_style = class_or_null_property(TextStyle) @@ -145,8 +145,8 @@ S7::method(to_list, LabelOption) <- function(x, ...) { LabelLine <- S7::new_class( "LabelLine", properties = list( - show = bool_or_null_property(), - show_above = bool_or_null_property(), + show = optional_logical_scalar, + show_above = optional_logical_scalar, length = numeric_or_null_property(), length2 = numeric_or_null_property(), smooth = S7::new_property( diff --git a/r/R/option.R b/r/R/option.R index 134726e..f70de30 100644 --- a/r/R/option.R +++ b/r/R/option.R @@ -54,13 +54,13 @@ EChartsOption <- S7::new_class( series = S7::new_property(class = S7::class_any, default = NULL), # Global settings color = color_palette_property(), - background_color = color_property(), + background_color = optional_character_scalar, text_style = class_or_null_property(TextStyle), # Animation - animation = bool_or_null_property(), + animation = optional_logical_scalar, animation_threshold = numeric_or_null_property(), animation_duration = numeric_or_null_property(), - animation_easing = string_or_null_property(), + animation_easing = optional_character_scalar, animation_delay = numeric_or_null_property(), # Other dark_mode = S7::new_property( @@ -79,7 +79,7 @@ EChartsOption <- S7::new_class( "must be TRUE, FALSE, 'auto', or NULL" } ), - use_utc = bool_or_null_property() + use_utc = optional_logical_scalar ) ) diff --git a/r/R/series.R b/r/R/series.R index 76f0e1b..f927d7f 100644 --- a/r/R/series.R +++ b/r/R/series.R @@ -66,7 +66,7 @@ MarkAreaDataPoint <- S7::new_class( "must be a single number, string, or NULL" } ), - name = string_or_null_property(), + name = optional_character_scalar, value = S7::new_property( class = S7::class_any, default = NULL, @@ -114,7 +114,7 @@ MarkArea <- S7::new_class( "MarkArea", properties = list( data = S7::new_property(class = S7::class_any, default = NULL), - silent = bool_or_null_property(), + silent = optional_logical_scalar, precision = numeric_or_null_property(), item_style = class_or_null_property(ItemStyle), label = class_or_null_property(LabelOption), @@ -175,14 +175,14 @@ LineSeries <- S7::new_class( "LineSeries", properties = list( # Common series fields - name = string_or_null_property(), + name = optional_character_scalar, data = S7::new_property(class = S7::class_any, default = NULL), x_axis_index = numeric_or_null_property(), y_axis_index = numeric_or_null_property(), - stack = string_or_null_property(), - silent = bool_or_null_property(), - legend_hover_link = bool_or_null_property(), - color = color_property(), + stack = optional_character_scalar, + silent = optional_logical_scalar, + legend_hover_link = optional_logical_scalar, + color = optional_character_scalar, z_level = numeric_or_null_property(), z = numeric_or_null_property(), # Line-specific @@ -222,10 +222,10 @@ LineSeries <- S7::new_class( "must be FALSE, 'start', 'end', 'middle', or NULL" } ), - connect_nulls = bool_or_null_property(), - clip = bool_or_null_property(), - show_symbol = bool_or_null_property(), - symbol = string_or_null_property(), + connect_nulls = optional_logical_scalar, + clip = optional_logical_scalar, + show_symbol = optional_logical_scalar, + symbol = optional_character_scalar, symbol_size = S7::new_property( class = S7::class_any, default = NULL, @@ -292,26 +292,26 @@ BarSeries <- S7::new_class( "BarSeries", properties = list( # Common series fields - name = string_or_null_property(), + name = optional_character_scalar, data = S7::new_property(class = S7::class_any, default = NULL), x_axis_index = numeric_or_null_property(), y_axis_index = numeric_or_null_property(), - stack = string_or_null_property(), - silent = bool_or_null_property(), - legend_hover_link = bool_or_null_property(), - color = color_property(), + stack = optional_character_scalar, + silent = optional_logical_scalar, + legend_hover_link = optional_logical_scalar, + color = optional_character_scalar, z_level = numeric_or_null_property(), z = numeric_or_null_property(), # Bar-specific - clip = bool_or_null_property(), + clip = optional_logical_scalar, bar_width = numeric_or_string_property(), bar_max_width = numeric_or_string_property(), bar_min_width = numeric_or_string_property(), bar_min_height = numeric_or_null_property(), bar_gap = numeric_or_string_property(), bar_category_gap = numeric_or_string_property(), - round_cap = bool_or_null_property(), - show_background = bool_or_null_property(), + round_cap = optional_logical_scalar, + show_background = optional_logical_scalar, item_style = class_or_null_property(ItemStyle), label = class_or_null_property(LabelOption) ) @@ -356,18 +356,18 @@ ScatterSeries <- S7::new_class( "ScatterSeries", properties = list( # Common series fields - name = string_or_null_property(), + name = optional_character_scalar, data = S7::new_property(class = S7::class_any, default = NULL), x_axis_index = numeric_or_null_property(), y_axis_index = numeric_or_null_property(), - silent = bool_or_null_property(), - legend_hover_link = bool_or_null_property(), - color = color_property(), + silent = optional_logical_scalar, + legend_hover_link = optional_logical_scalar, + color = optional_character_scalar, z_level = numeric_or_null_property(), z = numeric_or_null_property(), # Scatter-specific - clip = bool_or_null_property(), - symbol = string_or_null_property(), + clip = optional_logical_scalar, + symbol = optional_character_scalar, symbol_size = S7::new_property( class = S7::class_any, default = NULL, @@ -384,7 +384,7 @@ ScatterSeries <- S7::new_class( "must be a number, length-2 numeric vector, function, or NULL" } ), - large = bool_or_null_property(), + large = optional_logical_scalar, large_threshold = numeric_or_null_property(), item_style = class_or_null_property(ItemStyle), label = class_or_null_property(LabelOption) @@ -443,11 +443,11 @@ PieSeries <- S7::new_class( "PieSeries", properties = list( # Common series fields - name = string_or_null_property(), + name = optional_character_scalar, data = S7::new_property(class = S7::class_any, default = NULL), - silent = bool_or_null_property(), - legend_hover_link = bool_or_null_property(), - color = color_property(), + silent = optional_logical_scalar, + legend_hover_link = optional_logical_scalar, + color = optional_character_scalar, z_level = numeric_or_null_property(), z = numeric_or_null_property(), # Pie-specific @@ -477,8 +477,8 @@ PieSeries <- S7::new_class( "must be a number/string or length-2 vector, or NULL" } ), - rose_type = enum_property(c("radius", "area")), - clockwise = bool_or_null_property(), + rose_type = enum(c("radius", "area"), nullable = TRUE), + clockwise = optional_logical_scalar, start_angle = numeric_or_null_property(), end_angle = S7::new_property( class = S7::class_any, @@ -500,10 +500,10 @@ PieSeries <- S7::new_class( min_angle = numeric_or_null_property(), min_show_label_angle = numeric_or_null_property(), selected_offset = numeric_or_null_property(), - avoid_label_overlap = bool_or_null_property(), + avoid_label_overlap = optional_logical_scalar, percent_precision = numeric_or_null_property(), - still_show_zero_sum = bool_or_null_property(), - animation_type = enum_property(c("expansion", "scale")), + still_show_zero_sum = optional_logical_scalar, + animation_type = enum(c("expansion", "scale"), nullable = TRUE), item_style = class_or_null_property(ItemStyle), label = class_or_null_property(LabelOption), label_line = class_or_null_property(LabelLine), @@ -551,17 +551,17 @@ BoxplotSeries <- S7::new_class( "BoxplotSeries", properties = list( # Common series fields - name = string_or_null_property(), + name = optional_character_scalar, data = S7::new_property(class = S7::class_any, default = NULL), x_axis_index = numeric_or_null_property(), y_axis_index = numeric_or_null_property(), - silent = bool_or_null_property(), - legend_hover_link = bool_or_null_property(), - color = color_property(), + silent = optional_logical_scalar, + legend_hover_link = optional_logical_scalar, + color = optional_character_scalar, z_level = numeric_or_null_property(), z = numeric_or_null_property(), # Boxplot-specific - layout = enum_property(c("horizontal", "vertical")), + layout = enum(c("horizontal", "vertical"), nullable = TRUE), box_width = S7::new_property( class = S7::class_any, default = NULL, @@ -618,12 +618,12 @@ HeatmapSeries <- S7::new_class( "HeatmapSeries", properties = list( # Common series fields - name = string_or_null_property(), + name = optional_character_scalar, data = S7::new_property(class = S7::class_any, default = NULL), x_axis_index = numeric_or_null_property(), y_axis_index = numeric_or_null_property(), - silent = bool_or_null_property(), - legend_hover_link = bool_or_null_property(), + silent = optional_logical_scalar, + legend_hover_link = optional_logical_scalar, z_level = numeric_or_null_property(), z = numeric_or_null_property(), # Heatmap-specific diff --git a/r/R/series_sankey.R b/r/R/series_sankey.R new file mode 100644 index 0000000..dc8e0bf --- /dev/null +++ b/r/R/series_sankey.R @@ -0,0 +1,309 @@ +# series_sankey.R +# Sankey diagram S7 classes: SankeyNodeItem, SankeyEdgeItem, +# SankeyLevelOption, SankeySeries +# +# TS sources: +# SankeySeriesOption: src/chart/sankey/SankeySeries.ts (line 100) +# SankeyNodeItemOption: src/chart/sankey/SankeySeries.ts (line 73) +# SankeyEdgeItemOption: src/chart/sankey/SankeySeries.ts (line 88) +# SankeyLevelOption: src/chart/sankey/SankeySeries.ts (line 96) + +# -- SankeyNodeItem ------------------------------------------------------------- + +#' Sankey Node Item +#' +#' One node in a Sankey diagram. Node names must match the `source` / `target` +#' strings used in the corresponding [SankeyEdgeItem] objects. +#' +#' Corresponds to `SankeyNodeItemOption` in +#' `src/chart/sankey/SankeySeries.ts` (line 73). +#' ECharts docs: +#' \url{https://echarts.apache.org/en/option.html#series-sankey.data} +#' +#' @param name Optional Character: Node name (must match source/target strings). +#' @param value Optional Numeric: Node value shown in tooltip. +#' @param depth Optional Numeric `[0, Inf)`: Fixed column depth (0 = leftmost). +#' @param draggable Optional Logical: Whether the node can be dragged. +#' @param local_x Optional Numeric: Horizontal position inside the layout rect. +#' @param local_y Optional Numeric: Vertical position inside the layout rect. +#' @param label Optional [LabelOption]: Node label configuration. +#' @param item_style Optional [ItemStyle]: Node fill/border styling. +#' @param focus_node_adjacency Optional Logical or Character +#' \{"inEdges", "outEdges", "allEdges"\}: Which adjacent elements to highlight +#' on hover. +#' @export +SankeyNodeItem <- S7::new_class( + "SankeyNodeItem", + properties = list( + name = optional_character_scalar, + value = numeric_or_null_property(), + depth = optional_nonneg_integer_scalar, + draggable = optional_logical_scalar, + local_x = numeric_or_null_property(), + local_y = numeric_or_null_property(), + label = class_or_null_property(LabelOption), + item_style = class_or_null_property(ItemStyle), + focus_node_adjacency = S7::new_property( + class = S7::class_any, + default = NULL, + validator = function(value) { + if (is.null(value)) { + return(NULL) + } + if (is.logical(value) && length(value) == 1L) { + return(NULL) + } + if ( + is.character(value) && + length(value) == 1L && + value %in% c("inEdges", "outEdges", "allEdges") + ) { + return(NULL) + } + "must be TRUE/FALSE, 'inEdges', 'outEdges', 'allEdges', or NULL" + } + ) + ) +) + +S7::method(to_list, SankeyNodeItem) <- function(x, ...) { + props_to_list(x) +} + +# -- SankeyEdgeItem ------------------------------------------------------------- + +#' Sankey Edge Item +#' +#' One directed link (edge) in a Sankey diagram. +#' +#' Corresponds to `SankeyEdgeItemOption` in +#' `src/chart/sankey/SankeySeries.ts` (line 88). +#' ECharts docs: +#' \url{https://echarts.apache.org/en/option.html#series-sankey.links} +#' +#' @param source Optional Character: Source node name. +#' @param target Optional Character: Target node name. +#' @param value Optional Numeric: Flow value (determines link width). +#' @param line_style Optional [LineStyle] or list: Link line styling. Accepts +#' a [LineStyle] object or a plain named list; a list also allows the +#' Sankey-specific `curveness` field (e.g. `list(curveness = 0.5, +#' opacity = 0.3)`). +#' @param edge_label Optional [LabelOption]: Label shown on the link itself. +#' @param focus_node_adjacency Optional Logical or Character +#' \{"inEdges", "outEdges", "allEdges"\}: Which adjacent elements to highlight +#' on hover. +#' @export +SankeyEdgeItem <- S7::new_class( + "SankeyEdgeItem", + properties = list( + source = optional_character_scalar, + target = optional_character_scalar, + value = numeric_or_null_property(), + # Accepts LineStyle S7 object or a plain list (allows curveness extension) + line_style = S7::new_property(class = S7::class_any, default = NULL), + edge_label = class_or_null_property(LabelOption), + focus_node_adjacency = S7::new_property( + class = S7::class_any, + default = NULL, + validator = function(value) { + if (is.null(value)) { + return(NULL) + } + if (is.logical(value) && length(value) == 1L) { + return(NULL) + } + if ( + is.character(value) && + length(value) == 1L && + value %in% c("inEdges", "outEdges", "allEdges") + ) { + return(NULL) + } + "must be TRUE/FALSE, 'inEdges', 'outEdges', 'allEdges', or NULL" + } + ) + ) +) + +S7::method(to_list, SankeyEdgeItem) <- function(x, ...) { + out <- props_to_list(x) + # line_style may be a LineStyle S7 object; props_to_list handles recursion + # but the rename from snake_case is already applied. Ensure edge_label is + # serialized under "edgeLabel" (props_to_list does this via snake_to_camel). + out +} + +# -- SankeyLevelOption ---------------------------------------------------------- + +#' Sankey Level Option +#' +#' Style overrides applied to all nodes at a given column depth. `depth = 0` +#' targets the leftmost column. +#' +#' Corresponds to `SankeyLevelOption` in +#' `src/chart/sankey/SankeySeries.ts` (line 96). +#' ECharts docs: +#' \url{https://echarts.apache.org/en/option.html#series-sankey.levels} +#' +#' @param depth Numeric `[0, Inf)`: Column depth to target (0 = leftmost). Required. +#' @param label Optional [LabelOption]: Node label configuration at this depth. +#' @param item_style Optional [ItemStyle]: Node fill/border styling at this depth. +#' @param line_style Optional [LineStyle] or list: Link styling for edges leaving +#' this depth. Accepts a [LineStyle] object or a plain list (allows `curveness`). +#' @export +SankeyLevelOption <- S7::new_class( + "SankeyLevelOption", + properties = list( + depth = nonneg_integer_scalar, + label = class_or_null_property(LabelOption), + item_style = class_or_null_property(ItemStyle), + line_style = S7::new_property(class = S7::class_any, default = NULL) + ) +) + +S7::method(to_list, SankeyLevelOption) <- function(x, ...) { + props_to_list(x) +} + +# -- SankeySeries --------------------------------------------------------------- + +#' Sankey Series +#' +#' Configuration for a Sankey diagram series. Nodes and directed links are +#' represented as two separate lists. Node names in `data` must match the +#' `source` and `target` strings in `links`. +#' +#' Corresponds to `SankeySeriesOption` in +#' `src/chart/sankey/SankeySeries.ts` (line 100). +#' ECharts docs: \url{https://echarts.apache.org/en/option.html#series-sankey} +#' +#' @param name Optional Character: Series name for legend and tooltip. +#' @param data Optional list: Nodes as a list of [SankeyNodeItem] objects or +#' plain named lists, each with at least a `name` field. +#' @param links Optional list: Edges as a list of [SankeyEdgeItem] objects or +#' plain named lists with `source`, `target`, and `value` fields. +#' @param levels Optional list: List of [SankeyLevelOption] for per-depth styling. +#' @param orient Optional Character \{"horizontal", "vertical"\}: Flow direction. +#' @param node_width Optional Numeric `[0, Inf)`: Width of each node rectangle in pixels. +#' @param node_gap Optional Numeric `[0, Inf)`: Vertical gap between nodes in pixels. +#' @param node_align Optional Character \{"justify", "left", "right"\}: Node +#' alignment within columns. +#' @param draggable Optional Logical: Whether nodes can be dragged interactively. +#' @param layout_iterations Optional Numeric `[0, Inf)`: Number of layout iterations. +#' @param sort Optional Character \{"desc"\} or `NULL`: Column-internal node +#' ordering. `NULL` preserves the original data order. +#' @param roam Optional Logical or Character \{"move", "scale"\}: Whether to enable +#' pan/zoom. +#' @param label Optional [LabelOption]: Node label configuration. +#' @param edge_label Optional [LabelOption]: Edge label configuration. +#' @param line_style Optional [LineStyle] or list: Default link line styling. +#' Accepts a [LineStyle] object or a plain list; a plain list also allows the +#' Sankey-specific `curveness` field (e.g. `list(curveness = 0.5)`). +#' @param item_style Optional [ItemStyle]: Default node styling. +#' @param color Optional Character: Color palette for nodes as a character vector. +#' @param left Optional Numeric or Character: Series left offset. +#' @param right Optional Numeric or Character: Series right offset. +#' @param top Optional Numeric or Character: Series top offset. +#' @param bottom Optional Numeric or Character: Series bottom offset. +#' @param silent Optional Logical: Whether to disable mouse/touch events. +#' @param z_level Optional Numeric: Canvas layer index. +#' @param z Optional Numeric: Front-back order within the same canvas layer. +#' @export +SankeySeries <- S7::new_class( + "SankeySeries", + properties = list( + name = optional_character_scalar, + data = S7::new_property(class = S7::class_any, default = NULL), + links = S7::new_property(class = S7::class_any, default = NULL), + levels = S7::new_property(class = S7::class_any, default = NULL), + orient = enum(c("horizontal", "vertical"), nullable = TRUE), + node_width = numeric_or_null_property(), + node_gap = numeric_or_null_property(), + node_align = enum(c("justify", "left", "right"), nullable = TRUE), + draggable = optional_logical_scalar, + layout_iterations = numeric_or_null_property(), + sort = S7::new_property( + class = S7::class_any, + default = NULL, + validator = function(value) { + if (is.null(value)) { + return(NULL) + } + if (is.character(value) && length(value) == 1L && value == "desc") { + return(NULL) + } + "must be 'desc' or NULL" + } + ), + roam = S7::new_property( + class = S7::class_any, + default = NULL, + validator = function(value) { + if (is.null(value)) { + return(NULL) + } + if (is.logical(value) && length(value) == 1L) { + return(NULL) + } + if ( + is.character(value) && + length(value) == 1L && + value %in% c("move", "scale") + ) { + return(NULL) + } + "must be TRUE/FALSE, 'move', 'scale', or NULL" + } + ), + label = class_or_null_property(LabelOption), + edge_label = class_or_null_property(LabelOption), + # Accepts LineStyle S7 object or plain list (allows curveness extension) + line_style = S7::new_property(class = S7::class_any, default = NULL), + item_style = class_or_null_property(ItemStyle), + color = color_palette_property(), + left = numeric_or_string_property(), + right = numeric_or_string_property(), + top = numeric_or_string_property(), + bottom = numeric_or_string_property(), + silent = optional_logical_scalar, + z_level = numeric_or_null_property(), + z = numeric_or_null_property() + ) +) + +#' @keywords internal +#' @noRd +serialize_sankey_items <- function(items) { + if (is.null(items)) { + return(NULL) + } + out <- lapply(items, function(item) { + if (S7::S7_inherits(item)) to_list(item) else item + }) + unname(out) +} + +S7::method(to_list, SankeySeries) <- function(x, ...) { + out <- props_to_list(x) + out[["type"]] <- "sankey" + + # Serialize data (nodes) and links as unnamed arrays + if (!is.null(out[["data"]])) { + out[["data"]] <- serialize_sankey_items(x@data) + } + if (!is.null(out[["links"]])) { + out[["links"]] <- serialize_sankey_items(x@links) + } + + # Serialize levels as unnamed array + if (!is.null(out[["levels"]])) { + out[["levels"]] <- serialize_sankey_items(x@levels) + } + + # line_style: S7 object -> list; plain list passed through as-is + if (!is.null(out[["lineStyle"]]) && S7::S7_inherits(x@line_style)) { + out[["lineStyle"]] <- to_list(x@line_style) + } + + out +} diff --git a/r/R/styles.R b/r/R/styles.R index 81ec60e..03be27c 100644 --- a/r/R/styles.R +++ b/r/R/styles.R @@ -29,17 +29,17 @@ LineStyle <- S7::new_class( "LineStyle", properties = list( - color = color_property(), + color = optional_character_scalar, width = numeric_or_null_property(), opacity = numeric_or_null_property(), - type = enum_property(c("solid", "dashed", "dotted")), - cap = enum_property(c("butt", "round", "square")), - join = enum_property(c("bevel", "round", "miter")), + type = enum(c("solid", "dashed", "dotted"), nullable = TRUE), + cap = enum(c("butt", "round", "square"), nullable = TRUE), + join = enum(c("bevel", "round", "miter"), nullable = TRUE), dash_offset = numeric_or_null_property(), miter_limit = numeric_or_null_property(), # ShadowOptionMixin shadow_blur = numeric_or_null_property(), - shadow_color = color_property(), + shadow_color = optional_character_scalar, shadow_offset_x = numeric_or_null_property(), shadow_offset_y = numeric_or_null_property() ) @@ -69,7 +69,7 @@ S7::method(to_list, LineStyle) <- function(x, ...) { AreaStyle <- S7::new_class( "AreaStyle", properties = list( - color = color_property(), + color = optional_character_scalar, opacity = numeric_or_null_property(), origin = S7::new_property( class = S7::class_any, @@ -89,7 +89,7 @@ AreaStyle <- S7::new_class( ), # ShadowOptionMixin shadow_blur = numeric_or_null_property(), - shadow_color = color_property(), + shadow_color = optional_character_scalar, shadow_offset_x = numeric_or_null_property(), shadow_offset_y = numeric_or_null_property() ) @@ -128,14 +128,14 @@ S7::method(to_list, AreaStyle) <- function(x, ...) { ItemStyle <- S7::new_class( "ItemStyle", properties = list( - color = color_property(), + color = optional_character_scalar, opacity = numeric_or_null_property(), # BorderOptionMixin - border_color = color_property(), + border_color = optional_character_scalar, border_width = numeric_or_null_property(), - border_type = enum_property(c("solid", "dashed", "dotted")), - border_cap = enum_property(c("butt", "round", "square")), - border_join = enum_property(c("bevel", "round", "miter")), + border_type = enum(c("solid", "dashed", "dotted"), nullable = TRUE), + border_cap = enum(c("butt", "round", "square"), nullable = TRUE), + border_join = enum(c("bevel", "round", "miter"), nullable = TRUE), border_dash_offset = numeric_or_null_property(), border_miter_limit = numeric_or_null_property(), border_radius = S7::new_property( @@ -157,7 +157,7 @@ ItemStyle <- S7::new_class( decal = S7::new_property(class = S7::class_any, default = NULL), # ShadowOptionMixin shadow_blur = numeric_or_null_property(), - shadow_color = color_property(), + shadow_color = optional_character_scalar, shadow_offset_x = numeric_or_null_property(), shadow_offset_y = numeric_or_null_property() ) @@ -213,8 +213,8 @@ S7::method(to_list, ItemStyle) <- function(x, ...) { TextStyle <- S7::new_class( "TextStyle", properties = list( - color = color_property(), - font_style = enum_property(c("normal", "italic", "oblique")), + color = optional_character_scalar, + font_style = enum(c("normal", "italic", "oblique"), nullable = TRUE), font_weight = S7::new_property( class = S7::class_any, default = NULL, @@ -234,16 +234,16 @@ TextStyle <- S7::new_class( "must be 'normal', 'bold', 'bolder', 'lighter', a number, or NULL" } ), - font_family = string_or_null_property(), + font_family = optional_character_scalar, font_size = numeric_or_string_property(), - align = enum_property(c("left", "center", "right")), - vertical_align = enum_property(c("top", "middle", "bottom")), + align = enum(c("left", "center", "right"), nullable = TRUE), + vertical_align = enum(c("top", "middle", "bottom"), nullable = TRUE), opacity = numeric_or_null_property(), line_height = numeric_or_null_property(), - background_color = color_property(), - border_color = color_property(), + background_color = optional_character_scalar, + border_color = optional_character_scalar, border_width = numeric_or_null_property(), - border_type = enum_property(c("solid", "dashed", "dotted")), + border_type = enum(c("solid", "dashed", "dotted"), nullable = TRUE), border_dash_offset = numeric_or_null_property(), border_radius = S7::new_property( class = S7::class_any, @@ -273,20 +273,23 @@ TextStyle <- S7::new_class( ), width = numeric_or_string_property(), height = numeric_or_null_property(), - text_border_color = color_property(), + text_border_color = optional_character_scalar, text_border_width = numeric_or_null_property(), - text_border_type = enum_property(c("solid", "dashed", "dotted")), + text_border_type = enum(c("solid", "dashed", "dotted"), nullable = TRUE), text_border_dash_offset = numeric_or_null_property(), text_shadow_blur = numeric_or_null_property(), - text_shadow_color = color_property(), + text_shadow_color = optional_character_scalar, text_shadow_offset_x = numeric_or_null_property(), text_shadow_offset_y = numeric_or_null_property(), - overflow = enum_property(c("truncate", "break", "breakAll", "none")), - ellipsis = string_or_null_property(), + overflow = enum( + c("truncate", "break", "breakAll", "none"), + nullable = TRUE + ), + ellipsis = optional_character_scalar, rich = S7::new_property(class = S7::class_any, default = NULL), # ShadowOptionMixin (box-level shadow) shadow_blur = numeric_or_null_property(), - shadow_color = color_property(), + shadow_color = optional_character_scalar, shadow_offset_x = numeric_or_null_property(), shadow_offset_y = numeric_or_null_property() ) diff --git a/r/R/theme.R b/r/R/theme.R index 159d7ab..ceb0a0b 100644 --- a/r/R/theme.R +++ b/r/R/theme.R @@ -38,7 +38,7 @@ Theme <- S7::new_class( "Theme", properties = list( color = color_palette_property(), - background_color = color_property(), + background_color = optional_character_scalar, text_style = class_or_null_property(TextStyle), # Component overrides (plain lists) title = S7::new_property(class = S7::class_any, default = NULL), diff --git a/r/R/utils.R b/r/R/utils.R index dcacdd8..edd5f5c 100644 --- a/r/R/utils.R +++ b/r/R/utils.R @@ -35,7 +35,9 @@ snake_to_camel <- function(x) { #' @keywords internal #' @noRd drop_nulls <- function(x) { - x[!vapply(x, is.null, logical(1))] + # Also drop zero-length values: S7 0.2.2 stores empty prototypes (e.g. logical(0)) + # instead of NULL for new_union(type, NULL) properties with default = NULL. + x[!vapply(x, function(v) is.null(v) || length(v) == 0L, logical(1))] } #' Convert an S7 object to an echarts-compatible named list @@ -110,37 +112,6 @@ to_list <- S7::new_generic("to_list", "x") # -- Type validators for S7 properties ------------------------------------------- # These return validator functions or S7 class unions. -#' @keywords internal -#' @noRd -nullable <- function(type) { - type | S7::class_missing | NULL -} - -#' Validate that a value is one of the allowed choices -#' -#' Returns a custom S7 property object with validation. -#' -#' @param values Character: Allowed values. -#' @param default Optional Character: Default value. -#' @param nullable Logical: Whether `NULL` is accepted. -#' @return S7 property: Property definition. -#' @keywords internal -#' @noRd -enum_property <- function(values, default = NULL, nullable = TRUE) { - S7::new_property( - class = if (nullable) S7::class_any else S7::class_character, - default = default, - validator = function(value) { - if (is.null(value) && nullable) { - return(NULL) - } - if (!is.character(value) || length(value) != 1L || !(value %in% values)) { - paste0("must be one of: ", paste(dQuote(values), collapse = ", ")) - } - } - ) -} - #' Property that accepts a number or NULL #' @keywords internal #' @noRd @@ -159,42 +130,6 @@ numeric_or_null_property <- function(default = NULL) { ) } -#' Property that accepts a string or NULL -#' @keywords internal -#' @noRd -string_or_null_property <- function(default = NULL) { - S7::new_property( - class = S7::class_any, - default = default, - validator = function(value) { - if (is.null(value)) { - return(NULL) - } - if (!is.character(value) || length(value) != 1L) { - "must be a single string or NULL" - } - } - ) -} - -#' Property that accepts a logical or NULL -#' @keywords internal -#' @noRd -bool_or_null_property <- function(default = NULL) { - S7::new_property( - class = S7::class_any, - default = default, - validator = function(value) { - if (is.null(value)) { - return(NULL) - } - if (!is.logical(value) || length(value) != 1L) { - "must be TRUE, FALSE, or NULL" - } - } - ) -} - #' Property that accepts a number, string, or NULL #' #' Used for echarts fields that accept both pixel values (number) and @@ -377,16 +312,6 @@ validate_axis_lim <- function(value, arg) { } -#' Property that accepts a color string, or NULL -#' -#' Currently accepts any string. Future versions may validate -#' hex, rgb(), rgba(), hsl(), or named CSS colors. -#' @keywords internal -#' @noRd -color_property <- function(default = NULL) { - string_or_null_property(default = default) -} - #' Property that accepts a character vector of colors, or NULL #' #' Used for color palettes (e.g. theme and option `color` fields). diff --git a/r/man/SankeyEdgeItem.Rd b/r/man/SankeyEdgeItem.Rd new file mode 100644 index 0000000..4f1b5e0 --- /dev/null +++ b/r/man/SankeyEdgeItem.Rd @@ -0,0 +1,41 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/series_sankey.R +\name{SankeyEdgeItem} +\alias{SankeyEdgeItem} +\title{Sankey Edge Item} +\usage{ +SankeyEdgeItem( + source = NULL, + target = NULL, + value = NULL, + line_style = NULL, + edge_label = NULL, + focus_node_adjacency = NULL +) +} +\arguments{ +\item{source}{Optional Character: Source node name.} + +\item{target}{Optional Character: Target node name.} + +\item{value}{Optional Numeric: Flow value (determines link width).} + +\item{line_style}{Optional \link{LineStyle} or list: Link line styling. Accepts +a \link{LineStyle} object or a plain named list; a list also allows the +Sankey-specific \code{curveness} field (e.g. \code{list(curveness = 0.5, opacity = 0.3)}).} + +\item{edge_label}{Optional \link{LabelOption}: Label shown on the link itself.} + +\item{focus_node_adjacency}{Optional Logical or Character +\{"inEdges", "outEdges", "allEdges"\}: Which adjacent elements to highlight +on hover.} +} +\description{ +One directed link (edge) in a Sankey diagram. +} +\details{ +Corresponds to \code{SankeyEdgeItemOption} in +\code{src/chart/sankey/SankeySeries.ts} (line 88). +ECharts docs: +\url{https://echarts.apache.org/en/option.html#series-sankey.links} +} diff --git a/r/man/SankeyLevelOption.Rd b/r/man/SankeyLevelOption.Rd new file mode 100644 index 0000000..76c1162 --- /dev/null +++ b/r/man/SankeyLevelOption.Rd @@ -0,0 +1,33 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/series_sankey.R +\name{SankeyLevelOption} +\alias{SankeyLevelOption} +\title{Sankey Level Option} +\usage{ +SankeyLevelOption( + depth = integer(0), + label = NULL, + item_style = NULL, + line_style = NULL +) +} +\arguments{ +\item{depth}{Numeric \verb{[0, Inf)}: Column depth to target (0 = leftmost). Required.} + +\item{label}{Optional \link{LabelOption}: Node label configuration at this depth.} + +\item{item_style}{Optional \link{ItemStyle}: Node fill/border styling at this depth.} + +\item{line_style}{Optional \link{LineStyle} or list: Link styling for edges leaving +this depth. Accepts a \link{LineStyle} object or a plain list (allows \code{curveness}).} +} +\description{ +Style overrides applied to all nodes at a given column depth. \code{depth = 0} +targets the leftmost column. +} +\details{ +Corresponds to \code{SankeyLevelOption} in +\code{src/chart/sankey/SankeySeries.ts} (line 96). +ECharts docs: +\url{https://echarts.apache.org/en/option.html#series-sankey.levels} +} diff --git a/r/man/SankeyNodeItem.Rd b/r/man/SankeyNodeItem.Rd new file mode 100644 index 0000000..816cee7 --- /dev/null +++ b/r/man/SankeyNodeItem.Rd @@ -0,0 +1,49 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/series_sankey.R +\name{SankeyNodeItem} +\alias{SankeyNodeItem} +\title{Sankey Node Item} +\usage{ +SankeyNodeItem( + name = NULL, + value = NULL, + depth = NULL, + draggable = NULL, + local_x = NULL, + local_y = NULL, + label = NULL, + item_style = NULL, + focus_node_adjacency = NULL +) +} +\arguments{ +\item{name}{Optional Character: Node name (must match source/target strings).} + +\item{value}{Optional Numeric: Node value shown in tooltip.} + +\item{depth}{Optional Numeric \verb{[0, Inf)}: Fixed column depth (0 = leftmost).} + +\item{draggable}{Optional Logical: Whether the node can be dragged.} + +\item{local_x}{Optional Numeric: Horizontal position inside the layout rect.} + +\item{local_y}{Optional Numeric: Vertical position inside the layout rect.} + +\item{label}{Optional \link{LabelOption}: Node label configuration.} + +\item{item_style}{Optional \link{ItemStyle}: Node fill/border styling.} + +\item{focus_node_adjacency}{Optional Logical or Character +\{"inEdges", "outEdges", "allEdges"\}: Which adjacent elements to highlight +on hover.} +} +\description{ +One node in a Sankey diagram. Node names must match the \code{source} / \code{target} +strings used in the corresponding \link{SankeyEdgeItem} objects. +} +\details{ +Corresponds to \code{SankeyNodeItemOption} in +\code{src/chart/sankey/SankeySeries.ts} (line 73). +ECharts docs: +\url{https://echarts.apache.org/en/option.html#series-sankey.data} +} diff --git a/r/man/SankeySeries.Rd b/r/man/SankeySeries.Rd new file mode 100644 index 0000000..09542e2 --- /dev/null +++ b/r/man/SankeySeries.Rd @@ -0,0 +1,99 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/series_sankey.R +\name{SankeySeries} +\alias{SankeySeries} +\title{Sankey Series} +\usage{ +SankeySeries( + name = NULL, + data = NULL, + links = NULL, + levels = NULL, + orient = NULL, + node_width = NULL, + node_gap = NULL, + node_align = NULL, + draggable = NULL, + layout_iterations = NULL, + sort = NULL, + roam = NULL, + label = NULL, + edge_label = NULL, + line_style = NULL, + item_style = NULL, + color = NULL, + left = NULL, + right = NULL, + top = NULL, + bottom = NULL, + silent = NULL, + z_level = NULL, + z = NULL +) +} +\arguments{ +\item{name}{Optional Character: Series name for legend and tooltip.} + +\item{data}{Optional list: Nodes as a list of \link{SankeyNodeItem} objects or +plain named lists, each with at least a \code{name} field.} + +\item{links}{Optional list: Edges as a list of \link{SankeyEdgeItem} objects or +plain named lists with \code{source}, \code{target}, and \code{value} fields.} + +\item{levels}{Optional list: List of \link{SankeyLevelOption} for per-depth styling.} + +\item{orient}{Optional Character \{"horizontal", "vertical"\}: Flow direction.} + +\item{node_width}{Optional Numeric \verb{[0, Inf)}: Width of each node rectangle in pixels.} + +\item{node_gap}{Optional Numeric \verb{[0, Inf)}: Vertical gap between nodes in pixels.} + +\item{node_align}{Optional Character \{"justify", "left", "right"\}: Node +alignment within columns.} + +\item{draggable}{Optional Logical: Whether nodes can be dragged interactively.} + +\item{layout_iterations}{Optional Numeric \verb{[0, Inf)}: Number of layout iterations.} + +\item{sort}{Optional Character \{"desc"\} or \code{NULL}: Column-internal node +ordering. \code{NULL} preserves the original data order.} + +\item{roam}{Optional Logical or Character \{"move", "scale"\}: Whether to enable +pan/zoom.} + +\item{label}{Optional \link{LabelOption}: Node label configuration.} + +\item{edge_label}{Optional \link{LabelOption}: Edge label configuration.} + +\item{line_style}{Optional \link{LineStyle} or list: Default link line styling. +Accepts a \link{LineStyle} object or a plain list; a plain list also allows the +Sankey-specific \code{curveness} field (e.g. \code{list(curveness = 0.5)}).} + +\item{item_style}{Optional \link{ItemStyle}: Default node styling.} + +\item{color}{Optional Character: Color palette for nodes as a character vector.} + +\item{left}{Optional Numeric or Character: Series left offset.} + +\item{right}{Optional Numeric or Character: Series right offset.} + +\item{top}{Optional Numeric or Character: Series top offset.} + +\item{bottom}{Optional Numeric or Character: Series bottom offset.} + +\item{silent}{Optional Logical: Whether to disable mouse/touch events.} + +\item{z_level}{Optional Numeric: Canvas layer index.} + +\item{z}{Optional Numeric: Front-back order within the same canvas layer.} +} +\description{ +Configuration for a Sankey diagram series. Nodes and directed links are +represented as two separate lists. Node names in \code{data} must match the +\code{source} and \code{target} strings in \code{links}. +} +\details{ +Corresponds to \code{SankeySeriesOption} in +\code{src/chart/sankey/SankeySeries.ts} (line 100). +ECharts docs: \url{https://echarts.apache.org/en/option.html#series-sankey} +} diff --git a/r/man/draw_sankey.Rd b/r/man/draw_sankey.Rd new file mode 100644 index 0000000..6aa7def --- /dev/null +++ b/r/man/draw_sankey.Rd @@ -0,0 +1,63 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/draw.R +\name{draw_sankey} +\alias{draw_sankey} +\title{Draw a Sankey Diagram} +\usage{ +draw_sankey( + links, + orient = "horizontal", + node_width = NULL, + node_gap = NULL, + node_align = NULL, + title = NULL, + color = NULL, + theme = NULL, + width = NULL, + height = NULL, + filename = NULL +) +} +\arguments{ +\item{links}{data.frame: Directed links with columns \code{source} (Character), +\code{target} (Character), and \code{value} (Numeric).} + +\item{orient}{Optional Character \{"horizontal", "vertical"\}: Flow direction.} + +\item{node_width}{Optional Numeric \verb{[0, Inf)}: Node rectangle width in pixels.} + +\item{node_gap}{Optional Numeric \verb{[0, Inf)}: Vertical gap between nodes in pixels.} + +\item{node_align}{Optional Character \{"justify", "left", "right"\}: Node +alignment within each column.} + +\item{title}{Optional Character: Chart title.} + +\item{color}{Optional Character: Node color palette — a single color string or +a character vector that overrides the theme palette for this chart.} + +\item{theme}{Optional \link{Theme}: Theme override.} + +\item{width}{Optional Character or Numeric: Widget width.} + +\item{height}{Optional Character or Numeric: Widget height.} + +\item{filename}{Optional Character: If provided, save the widget to this file +via \code{\link[=save_drawing]{save_drawing()}}.} +} +\value{ +htmlwidget: Widget object. +} +\description{ +Sankey diagram from a data frame of directed links. Node names are derived +automatically from the unique values in the \code{source} and \code{target} columns; +no separate node list is required. +} +\examples{ +links <- data.frame( + source = c("A", "A", "B", "C"), + target = c("B", "C", "D", "D"), + value = c(8, 4, 6, 3) +) +draw_sankey(links) +} diff --git a/r/man/draw_spectrogram.Rd b/r/man/draw_spectrogram.Rd index cf4af16..e1dac69 100644 --- a/r/man/draw_spectrogram.Rd +++ b/r/man/draw_spectrogram.Rd @@ -30,7 +30,7 @@ draw_spectrogram( xlab = NULL, ylab = NULL, theme = NULL, - margins = DEFAULT_MARGINS, + margins = NULL, width = NULL, height = NULL, filename = NULL diff --git a/r/man/rtemis.draw-package.Rd b/r/man/rtemis.draw-package.Rd index 55b73f5..bb5f912 100644 --- a/r/man/rtemis.draw-package.Rd +++ b/r/man/rtemis.draw-package.Rd @@ -23,4 +23,9 @@ Useful links: \author{ \strong{Maintainer}: E.D. Gennatas \email{gennatas@gmail.com} (\href{https://orcid.org/0000-0001-9280-3609}{ORCID}) [copyright holder] +Authors: +\itemize{ + \item E.D. Gennatas \email{gennatas@gmail.com} (\href{https://orcid.org/0000-0001-9280-3609}{ORCID}) [copyright holder] +} + } diff --git a/r/man/rtemis_colors.Rd b/r/man/rtemis_colors.Rd index 00c6cad..5cc91d4 100644 --- a/r/man/rtemis_colors.Rd +++ b/r/man/rtemis_colors.Rd @@ -1,12 +1,8 @@ % Generated by roxygen2: do not edit by hand % Please edit documentation in R/rtemis_color_system.R -\docType{data} \name{rtemis_colors} \alias{rtemis_colors} \title{rtemis Color Palette} -\format{ -An object of class \code{character} of length 10. -} \usage{ rtemis_colors } @@ -16,4 +12,3 @@ rtemis Color Palette \author{ EDG } -\keyword{datasets} diff --git a/r/man/to_json.Rd b/r/man/to_json.Rd index 28f9c73..f0b7c3b 100644 --- a/r/man/to_json.Rd +++ b/r/man/to_json.Rd @@ -13,7 +13,7 @@ to_json(x, pretty = FALSE, auto_unbox = TRUE, ...) \item{auto_unbox}{Logical: Whether to auto-unbox single-element vectors.} -\item{...}{Dots: Additional arguments passed to \code{\link[jsonlite:fromJSON]{jsonlite::toJSON()}}.} +\item{...}{Dots: Additional arguments passed to \code{\link[jsonlite:toJSON]{jsonlite::toJSON()}}.} } \value{ Character: JSON string. diff --git a/r/tests/testthat/test-series_sankey.R b/r/tests/testthat/test-series_sankey.R new file mode 100644 index 0000000..519d06c --- /dev/null +++ b/r/tests/testthat/test-series_sankey.R @@ -0,0 +1,294 @@ +# test-series_sankey.R +# Tests for SankeyNodeItem, SankeyEdgeItem, SankeyLevelOption, +# SankeySeries, and draw_sankey() + +# -- SankeyNodeItem ------------------------------------------------------------- + +test_that("SankeyNodeItem creates with defaults", { + n <- SankeyNodeItem() + expect_true(S7::S7_inherits(n, SankeyNodeItem)) + out <- to_list(n) + expect_equal(length(out), 0L) +}) + +test_that("SankeyNodeItem to_list() converts names correctly", { + n <- SankeyNodeItem( + name = "A", + value = 10, + depth = 1L, + draggable = FALSE, + local_x = 0.1, + local_y = 0.5 + ) + out <- to_list(n) + expect_equal(out[["name"]], "A") + expect_equal(out[["value"]], 10) + expect_equal(out[["depth"]], 1L) + expect_equal(out[["draggable"]], FALSE) + expect_equal(out[["localX"]], 0.1) + expect_equal(out[["localY"]], 0.5) +}) + +test_that("SankeyNodeItem focus_node_adjacency validation", { + expect_equal( + SankeyNodeItem(focus_node_adjacency = TRUE)@focus_node_adjacency, + TRUE + ) + expect_equal( + SankeyNodeItem(focus_node_adjacency = "inEdges")@focus_node_adjacency, + "inEdges" + ) + expect_equal( + SankeyNodeItem(focus_node_adjacency = "outEdges")@focus_node_adjacency, + "outEdges" + ) + expect_equal( + SankeyNodeItem(focus_node_adjacency = "allEdges")@focus_node_adjacency, + "allEdges" + ) + expect_error(SankeyNodeItem(focus_node_adjacency = "bad")) + expect_error(SankeyNodeItem(focus_node_adjacency = 1)) +}) + +test_that("SankeyNodeItem depth validates non-negative integer scalar", { + expect_equal(SankeyNodeItem(depth = 0L)@depth, 0L) + expect_equal(SankeyNodeItem(depth = 2L)@depth, 2L) + expect_error(SankeyNodeItem(depth = -1L)) + expect_error(SankeyNodeItem(depth = 1.5)) +}) + +test_that("SankeyNodeItem NULL fields are dropped", { + n <- SankeyNodeItem(name = "X") + out <- to_list(n) + expect_true("name" %in% names(out)) + expect_false("value" %in% names(out)) + expect_false("depth" %in% names(out)) +}) + +# -- SankeyEdgeItem ------------------------------------------------------------- + +test_that("SankeyEdgeItem creates with defaults", { + e <- SankeyEdgeItem() + expect_true(S7::S7_inherits(e, SankeyEdgeItem)) + out <- to_list(e) + expect_equal(length(out), 0L) +}) + +test_that("SankeyEdgeItem to_list() converts names correctly", { + e <- SankeyEdgeItem(source = "A", target = "B", value = 5) + out <- to_list(e) + expect_equal(out[["source"]], "A") + expect_equal(out[["target"]], "B") + expect_equal(out[["value"]], 5) +}) + +test_that("SankeyEdgeItem accepts plain list for line_style (curveness)", { + e <- SankeyEdgeItem( + source = "A", + target = "B", + value = 3, + line_style = list(curveness = 0.5, opacity = 0.3) + ) + out <- to_list(e) + expect_equal(out[["lineStyle"]][["curveness"]], 0.5) + expect_equal(out[["lineStyle"]][["opacity"]], 0.3) +}) + +test_that("SankeyEdgeItem accepts LineStyle S7 object for line_style", { + e <- SankeyEdgeItem( + source = "X", + target = "Y", + value = 2, + line_style = LineStyle(opacity = 0.4) + ) + out <- to_list(e) + expect_equal(out[["lineStyle"]][["opacity"]], 0.4) +}) + +# -- SankeyLevelOption ---------------------------------------------------------- + +test_that("SankeyLevelOption creates with depth", { + lv <- SankeyLevelOption(depth = 0L) + expect_true(S7::S7_inherits(lv, SankeyLevelOption)) + out <- to_list(lv) + expect_equal(out[["depth"]], 0L) +}) + +test_that("SankeyLevelOption depth is required", { + expect_error(SankeyLevelOption()) +}) + +test_that("SankeyLevelOption depth rejects negative", { + expect_error(SankeyLevelOption(depth = -1)) +}) + +test_that("SankeyLevelOption depth rejects non-integer and negative", { + expect_error(SankeyLevelOption(depth = 1.5)) + expect_error(SankeyLevelOption(depth = -1L)) +}) + +test_that("SankeyLevelOption to_list() includes item_style", { + lv <- SankeyLevelOption( + depth = 1L, + item_style = ItemStyle(color = "#f00") + ) + out <- to_list(lv) + expect_equal(out[["depth"]], 1L) + expect_equal(out[["itemStyle"]][["color"]], "#f00") +}) + +# -- SankeySeries --------------------------------------------------------------- + +test_that("SankeySeries creates with defaults", { + s <- SankeySeries() + expect_true(S7::S7_inherits(s, SankeySeries)) + out <- to_list(s) + expect_equal(out[["type"]], "sankey") + expect_equal(length(out), 1L) +}) + +test_that("SankeySeries to_list() sets type = 'sankey'", { + s <- SankeySeries(name = "flow") + out <- to_list(s) + expect_equal(out[["type"]], "sankey") + expect_equal(out[["name"]], "flow") +}) + +test_that("SankeySeries data and links serialize as unnamed arrays", { + s <- SankeySeries( + data = list( + list(name = "A"), + list(name = "B"), + list(name = "C") + ), + links = list( + list(source = "A", target = "B", value = 5), + list(source = "A", target = "C", value = 3) + ) + ) + out <- to_list(s) + expect_null(names(out[["data"]])) + expect_null(names(out[["links"]])) + expect_equal(length(out[["data"]]), 3L) + expect_equal(length(out[["links"]]), 2L) + expect_equal(out[["data"]][[1L]][["name"]], "A") + expect_equal(out[["links"]][[1L]][["source"]], "A") + expect_equal(out[["links"]][[2L]][["value"]], 3) +}) + +test_that("SankeySeries data accepts SankeyNodeItem objects", { + s <- SankeySeries( + data = list(SankeyNodeItem(name = "X", depth = 0L)), + links = list(list(source = "X", target = "Y", value = 1)) + ) + out <- to_list(s) + expect_equal(out[["data"]][[1L]][["name"]], "X") + expect_equal(out[["data"]][[1L]][["depth"]], 0L) +}) + +test_that("SankeySeries orient validation", { + expect_equal(SankeySeries(orient = "vertical")@orient, "vertical") + expect_equal(SankeySeries(orient = "horizontal")@orient, "horizontal") + expect_error(SankeySeries(orient = "diagonal")) +}) + +test_that("SankeySeries sort validation", { + expect_equal(SankeySeries(sort = "desc")@sort, "desc") + expect_null(SankeySeries(sort = NULL)@sort) + expect_error(SankeySeries(sort = "asc")) +}) + +test_that("SankeySeries roam validation", { + expect_equal(SankeySeries(roam = TRUE)@roam, TRUE) + expect_equal(SankeySeries(roam = "move")@roam, "move") + expect_equal(SankeySeries(roam = "scale")@roam, "scale") + expect_error(SankeySeries(roam = "zoom")) +}) + +test_that("SankeySeries levels serialize as unnamed array", { + s <- SankeySeries( + levels = list( + SankeyLevelOption(depth = 0L, item_style = ItemStyle(color = "#f00")), + SankeyLevelOption(depth = 1L) + ) + ) + out <- to_list(s) + expect_null(names(out[["levels"]])) + expect_equal(length(out[["levels"]]), 2L) + expect_equal(out[["levels"]][[1L]][["depth"]], 0) + expect_equal(out[["levels"]][[1L]][["itemStyle"]][["color"]], "#f00") +}) + +test_that("SankeySeries line_style accepts plain list with curveness", { + s <- SankeySeries(line_style = list(curveness = 0.5, opacity = 0.2)) + out <- to_list(s) + expect_equal(out[["lineStyle"]][["curveness"]], 0.5) +}) + +test_that("SankeySeries NULL fields are dropped", { + s <- SankeySeries(orient = "horizontal") + out <- to_list(s) + expect_true("orient" %in% names(out)) + expect_false("name" %in% names(out)) + expect_false("data" %in% names(out)) +}) + +# -- draw_sankey ---------------------------------------------------------------- + +test_that("draw_sankey returns an htmlwidget", { + links <- data.frame( + source = c("A", "A", "B"), + target = c("B", "C", "C"), + value = c(5, 3, 4), + stringsAsFactors = FALSE + ) + w <- draw_sankey(links) + expect_true(inherits(w, "htmlwidget")) +}) + +test_that("draw_sankey derives nodes from links automatically", { + links <- data.frame( + source = c("X", "Y"), + target = c("Y", "Z"), + value = c(10, 6), + stringsAsFactors = FALSE + ) + w <- draw_sankey(links) + opt <- w[["x"]][["option"]] + node_names <- vapply(opt[["series"]][[1L]][["data"]], `[[`, "", "name") + expect_setequal(node_names, c("X", "Y", "Z")) +}) + +test_that("draw_sankey rejects non-tabular links", { + expect_error(draw_sankey(list(source = "A", target = "B", value = 1))) +}) + +test_that("draw_sankey rejects links missing required columns", { + bad <- data.frame(from = "A", to = "B", stringsAsFactors = FALSE) + expect_error(draw_sankey(bad), regexp = "source") +}) + +test_that("draw_sankey handles factor source/target columns", { + links <- data.frame( + source = factor(c("A", "B"), levels = c("A", "B")), + target = factor(c("B", "C"), levels = c("B", "C")), + value = c(5, 3) + ) + w <- draw_sankey(links) + opt <- w[["x"]][["option"]] + node_names <- vapply(opt[["series"]][[1L]][["data"]], `[[`, "", "name") + expect_setequal(node_names, c("A", "B", "C")) + expect_type(node_names, "character") +}) + +test_that("draw_sankey respects orient argument", { + links <- data.frame( + source = "A", + target = "B", + value = 1, + stringsAsFactors = FALSE + ) + w <- draw_sankey(links, orient = "vertical") + series <- w[["x"]][["option"]][["series"]][[1L]] + expect_equal(series[["orient"]], "vertical") +})