Skip to content

Commit 90c4391

Browse files
zorroozclaude
andcommitted
feat: add relation marks: beeswarm, sankey, treemap, network, chord
- mark_beeswarm: wraps ggbeeswarm::geom_beeswarm (G2 beeswarm corelib) All deps in Suggests with runtime requireNamespace checks Tests use skip_if_not_installed() for optional packages - mark_sankey: wraps ggsankey::geom_sankey (G2 sankey graphlib) Auto-adds text labels for node names - mark_treemap: wraps treemapify::geom_treemap (G2 treemap graphlib) - mark_network: wraps ggraph + igraph (G2 forceGraph graphlib) Layout: auto (FR), circle, linear, bipartite, manual Replaces the entire gg object with ggraph output - mark_chord: wraps circlize::chordDiagram (G2 chord graphlib) Accepts adjacency matrix or from/to/value data frames - Added ggbeeswarm, ggsankey, treemapify, ggraph, igraph, circlize to DESCRIPTION Suggests - All 5 registered as unsupported on plotit_composite - Tests: 15 new tests with proper skip conditions - 402 pass, 0 fail Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bf87be6 commit 90c4391

10 files changed

Lines changed: 782 additions & 0 deletions

File tree

DESCRIPTION

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ RoxygenNote: 7.3.3
1111
URL: https://github.com/zorrooz/plotit, https://zorrooz.github.io/plotit/
1212
BugReports: https://github.com/zorrooz/plotit/issues
1313
Suggests:
14+
ggbeeswarm,
15+
ggsankey,
16+
treemapify,
17+
ggraph,
18+
igraph,
19+
circlize,
1420
ggrepel,
1521
testthat (>= 3.0.0),
1622
ggrastr,

NAMESPACE

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ export(make_mark)
1919
export(make_theme)
2020
export(mark_area)
2121
export(mark_bar)
22+
export(mark_beeswarm)
2223
export(mark_boxplot)
24+
export(mark_chord)
2325
export(mark_corr)
2426
export(mark_density)
2527
export(mark_density_2d)
@@ -30,14 +32,17 @@ export(mark_histogram)
3032
export(mark_line)
3133
export(mark_lollipop)
3234
export(mark_map)
35+
export(mark_network)
3336
export(mark_path)
3437
export(mark_point)
3538
export(mark_polygon)
3639
export(mark_rect)
3740
export(mark_rule)
41+
export(mark_sankey)
3842
export(mark_significance)
3943
export(mark_smooth)
4044
export(mark_text)
45+
export(mark_treemap)
4146
export(mark_violin)
4247
export(plotit)
4348
export(project_cartesian)

R/mark.R

Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,6 +1095,358 @@ S7::method(mark_dumbbell, plotit_class) <- function(
10951095
plot
10961096
}
10971097

1098+
# ---- mark_beeswarm ----
1099+
#' Beeswarm plot layer
1100+
#'
1101+
#' Adds a beeswarm (quasirandom scatter) layer to avoid overplotting
1102+
#' for one-dimensional distributions. Requires the
1103+
#' \pkg{ggbeeswarm} package.
1104+
#'
1105+
#' @param plot A plotit object
1106+
#' @param mapping Optional new aesthetics
1107+
#' @param data Optional data for this layer
1108+
#' @param position Position adjustment.
1109+
#' @param method Method for point placement:
1110+
#' `"swarm"`, `"compactswarm"`, `"hex"`, `"square"`,
1111+
#' `"center"`, or `"centre"`.
1112+
#' @param rasterize If `TRUE`, rasterize via `ggrastr::rasterise()`.
1113+
#' @param rasterize_dpi DPI for rasterization (default 300).
1114+
#' @param rasterize_dev Graphics device for rasterization (default `"cairo"`).
1115+
#' @param ... Other arguments passed to `geom_beeswarm`
1116+
#' @return Modified plotit object
1117+
#' @references
1118+
#' AntV G2: \href{https://g2.antv.antgroup.com/en/api/mark/beeswarm}{Beeswarm} (corelib)
1119+
#' @examples
1120+
#' \donttest{
1121+
#' if (requireNamespace("ggbeeswarm", quietly = TRUE)) {
1122+
#' plotit(iris, encode(x = Species, y = Sepal.Length)) |>
1123+
#' mark_beeswarm()
1124+
#' }
1125+
#' }
1126+
#' @export
1127+
mark_beeswarm <- S7::new_generic(
1128+
"mark_beeswarm", "plot",
1129+
function(plot, mapping = NULL, data = NULL, position = NULL, ...,
1130+
method = c("swarm", "compactswarm", "hex", "square", "center", "centre"),
1131+
rasterize = FALSE, rasterize_dpi = 300, rasterize_dev = "cairo") {
1132+
S7::S7_dispatch()
1133+
}
1134+
)
1135+
1136+
#' @export
1137+
S7::method(mark_beeswarm, plotit_class) <- function(
1138+
plot, mapping = NULL, data = NULL, position = NULL, ...,
1139+
method = c("swarm", "compactswarm", "hex", "square", "center", "centre"),
1140+
rasterize = FALSE, rasterize_dpi = 300, rasterize_dev = "cairo") {
1141+
if (!requireNamespace("ggbeeswarm", quietly = TRUE)) {
1142+
cli::cli_abort("{.fn mark_beeswarm} requires the {.pkg ggbeeswarm} package.")
1143+
}
1144+
method <- match.arg(method)
1145+
params <- rlang::list2(...)
1146+
params$method <- method[1]
1147+
do.call(function(...) {
1148+
._mark_impl(plot, mapping, data, position, ggbeeswarm::geom_beeswarm,
1149+
rasterize, rasterize_dpi, rasterize_dev, ...)
1150+
}, params)
1151+
}
1152+
1153+
# ---- mark_sankey ----
1154+
#' Sankey flow diagram layer
1155+
#'
1156+
#' Creates a Sankey diagram showing directed flows between nodes.
1157+
#' Requires the \pkg{ggsankey} package. Data should contain
1158+
#' `x`, `next_x`, `node`, and `next_node` columns as generated by
1159+
#' \code{ggsankey::make_long}.
1160+
#'
1161+
#' @param plot A plotit object
1162+
#' @param mapping Optional new aesthetics. The default expects
1163+
#' `x`, `next_x`, `node`, `next_node`, and optionally `value`.
1164+
#' @param data Optional data for this layer
1165+
#' @param position Position adjustment (rarely used for Sankey).
1166+
#' @param node_colour Colour for node rectangles (default `"grey30"`).
1167+
#' @param flow_alpha Alpha transparency for flow ribbons (default 0.5).
1168+
#' @param rasterize If `TRUE`, rasterize via `ggrastr::rasterise()`.
1169+
#' @param rasterize_dpi DPI for rasterization (default 300).
1170+
#' @param rasterize_dev Graphics device for rasterization (default `"cairo"`).
1171+
#' @param ... Other arguments passed to `geom_sankey`
1172+
#' @return Modified plotit object
1173+
#' @references
1174+
#' AntV G2: \href{https://g2.antv.antgroup.com/en/api/mark/sankey}{Sankey} (graphlib)
1175+
#' @examples
1176+
#' \donttest{
1177+
#' if (requireNamespace("ggsankey", quietly = TRUE)) {
1178+
#' df <- ggsankey::make_long(ggplot2::diamonds, cut, color)
1179+
#' plotit(df, encode(x = x, next_x = next_x, node = node,
1180+
#' next_node = next_node, value = value)) |>
1181+
#' mark_sankey()
1182+
#' }
1183+
#' }
1184+
#' @export
1185+
mark_sankey <- S7::new_generic(
1186+
"mark_sankey", "plot",
1187+
function(plot, mapping = NULL, data = NULL, position = NULL, ...,
1188+
node_colour = "grey30", flow_alpha = 0.5,
1189+
rasterize = FALSE, rasterize_dpi = 300, rasterize_dev = "cairo") {
1190+
S7::S7_dispatch()
1191+
}
1192+
)
1193+
1194+
#' @export
1195+
S7::method(mark_sankey, plotit_class) <- function(
1196+
plot, mapping = NULL, data = NULL, position = NULL, ...,
1197+
node_colour = "grey30", flow_alpha = 0.5,
1198+
rasterize = FALSE, rasterize_dpi = 300, rasterize_dev = "cairo") {
1199+
if (!requireNamespace("ggsankey", quietly = TRUE)) {
1200+
cli::cli_abort("{.fn mark_sankey} requires the {.pkg ggsankey} package.")
1201+
}
1202+
if (!is.null(mapping) && !is.null(mapping$colour)) {
1203+
plot <- ._clear_default_color(plot, mapping)
1204+
}
1205+
pos <- position
1206+
if (is.null(pos) && !is.null(plot@meta@dodge) && plot@meta@dodge > 0) {
1207+
pos <- ggplot2::position_dodge(plot@meta@dodge)
1208+
}
1209+
# Sankey requires both a geom_sankey flow + a geom_sankey_text layer
1210+
# Add flow ribbon
1211+
geom_flow <- if (is.null(pos)) {
1212+
ggsankey::geom_sankey(mapping = mapping, data = data,
1213+
node.fill = node_colour, alpha = flow_alpha, ...)
1214+
} else {
1215+
ggsankey::geom_sankey(mapping = mapping, data = data, position = pos,
1216+
node.fill = node_colour, alpha = flow_alpha, ...)
1217+
}
1218+
plot <- .add_geom(plot, geom_flow,
1219+
rasterize = rasterize, rasterize_dpi = rasterize_dpi,
1220+
rasterize_dev = rasterize_dev
1221+
)
1222+
# Add node labels
1223+
plot <- plot |>
1224+
mark_text(mapping = mapping, data = data, repel = FALSE,
1225+
check_overlap = FALSE, size = 3)
1226+
plot
1227+
}
1228+
1229+
# ---- mark_treemap ----
1230+
#' Treemap layer
1231+
#'
1232+
#' Creates a treemap showing hierarchical data as nested rectangles.
1233+
#' Requires the \pkg{treemapify} package. Data should contain
1234+
#' `area`, `subgroup`, and optionally `subgroup2` columns.
1235+
#'
1236+
#' @param plot A plotit object
1237+
#' @param mapping Optional new aesthetics. Must include `area` for
1238+
#' rectangle sizing.
1239+
#' @param data Optional data for this layer
1240+
#' @param position Position adjustment.
1241+
#' @param rasterize If `TRUE`, rasterize via `ggrastr::rasterise()`.
1242+
#' @param rasterize_dpi DPI for rasterization (default 300).
1243+
#' @param rasterize_dev Graphics device for rasterization (default `"cairo"`).
1244+
#' @param ... Other arguments passed to `geom_treemap`
1245+
#' @return Modified plotit object
1246+
#' @references
1247+
#' AntV G2: \href{https://g2.antv.antgroup.com/en/api/mark/treemap}{Treemap} (graphlib)
1248+
#' @examples
1249+
#' \donttest{
1250+
#' if (requireNamespace("treemapify", quietly = TRUE)) {
1251+
#' df <- data.frame(
1252+
#' group = c("A", "B", "C"),
1253+
#' subgroup = c("a1", "a2", "b1"),
1254+
#' size = c(30, 20, 50))
1255+
#' plotit(df, encode(area = size, fill = group,
1256+
#' subgroup = subgroup)) |>
1257+
#' mark_treemap()
1258+
#' }
1259+
#' }
1260+
#' @export
1261+
mark_treemap <- S7::new_generic(
1262+
"mark_treemap", "plot",
1263+
function(plot, mapping = NULL, data = NULL, position = NULL, ...,
1264+
rasterize = FALSE, rasterize_dpi = 300, rasterize_dev = "cairo") {
1265+
S7::S7_dispatch()
1266+
}
1267+
)
1268+
1269+
#' @export
1270+
S7::method(mark_treemap, plotit_class) <- function(
1271+
plot, mapping = NULL, data = NULL, position = NULL, ...,
1272+
rasterize = FALSE, rasterize_dpi = 300, rasterize_dev = "cairo") {
1273+
if (!requireNamespace("treemapify", quietly = TRUE)) {
1274+
cli::cli_abort("{.fn mark_treemap} requires the {.pkg treemapify} package.")
1275+
}
1276+
if (!is.null(mapping) && !is.null(mapping$fill)) {
1277+
plot <- ._clear_default_color(plot, mapping)
1278+
}
1279+
pos <- position
1280+
if (is.null(pos) && !is.null(plot@meta@dodge) && plot@meta@dodge > 0) {
1281+
pos <- ggplot2::position_dodge(plot@meta@dodge)
1282+
}
1283+
geom <- if (is.null(pos)) {
1284+
treemapify::geom_treemap(mapping = mapping, data = data, ...)
1285+
} else {
1286+
treemapify::geom_treemap(mapping = mapping, data = data, position = pos, ...)
1287+
}
1288+
plot <- .add_geom(plot, geom,
1289+
rasterize = rasterize, rasterize_dpi = rasterize_dpi,
1290+
rasterize_dev = rasterize_dev
1291+
)
1292+
plot
1293+
}
1294+
1295+
# ---- mark_network ----
1296+
#' Network / force-directed graph layer
1297+
#'
1298+
#' Creates a network visualization with nodes and edges.
1299+
#' Requires the \pkg{ggraph} and \pkg{igraph} packages.
1300+
#'
1301+
#' @param plot A plotit object. The data should be an `igraph`
1302+
#' object.
1303+
#' @param layout Layout algorithm: `"auto"` (default, uses
1304+
#' `layout_with_fr`), `"circle"`, `"linear"`, `"bipartite"`,
1305+
#' or `"manual"`.
1306+
#' @param edge_colour Colour for edges (default `"grey70"`).
1307+
#' @param edge_width Width for edges (default 0.5).
1308+
#' @param node_colour Fill colour for nodes (default `"#4E79A7"`).
1309+
#' @param node_size Size for nodes (default 5).
1310+
#' @param ... Other arguments passed to `ggraph::geom_edge_link`
1311+
#' and `ggraph::geom_node_point`
1312+
#' @return Modified plotit object
1313+
#' @references
1314+
#' AntV G2: \href{https://g2.antv.antgroup.com/en/api/mark/force-graph}{ForceGraph} (graphlib)
1315+
#' @examples
1316+
#' \donttest{
1317+
#' if (requireNamespace("ggraph", quietly = TRUE) &&
1318+
#' requireNamespace("igraph", quietly = TRUE)) {
1319+
#' gr <- igraph::sample_pa(30)
1320+
#' plotit(gr, encode()) |> mark_network()
1321+
#' }
1322+
#' }
1323+
#' @export
1324+
mark_network <- S7::new_generic(
1325+
"mark_network", "plot",
1326+
function(plot,
1327+
layout = c("auto", "circle", "linear", "bipartite", "manual"),
1328+
edge_colour = "grey70", edge_width = 0.5,
1329+
node_colour = "#4E79A7", node_size = 5, ...) {
1330+
S7::S7_dispatch()
1331+
}
1332+
)
1333+
1334+
#' @export
1335+
S7::method(mark_network, plotit_class) <- function(
1336+
plot,
1337+
layout = c("auto", "circle", "linear", "bipartite", "manual"),
1338+
edge_colour = "grey70", edge_width = 0.5,
1339+
node_colour = "#4E79A7", node_size = 5, ...) {
1340+
if (!requireNamespace("ggraph", quietly = TRUE)) {
1341+
cli::cli_abort("{.fn mark_network} requires the {.pkg ggraph} package.")
1342+
}
1343+
if (!requireNamespace("igraph", quietly = TRUE)) {
1344+
cli::cli_abort("{.fn mark_network} requires the {.pkg igraph} package.")
1345+
}
1346+
layout <- match.arg(layout)
1347+
graph_data <- plot@gg$data
1348+
if (!inherits(graph_data, "igraph")) {
1349+
cli::cli_abort(
1350+
"{.fn mark_network} requires an {.cls igraph} object as plot data."
1351+
)
1352+
}
1353+
layout_fun <- switch(layout,
1354+
auto = igraph::layout_with_fr,
1355+
circle = igraph::layout_in_circle,
1356+
linear = igraph::layout_on_line,
1357+
bipartite = igraph::layout_as_bipartite,
1358+
manual = igraph::layout_nicely
1359+
)
1360+
gg <- ggraph::ggraph(graph_data, layout = layout_fun)
1361+
gg <- gg + ggplot2::theme_void()
1362+
# Edge layer
1363+
gg <- gg + ggraph::geom_edge_link(edge_colour = edge_colour,
1364+
edge_width = edge_width, ...)
1365+
# Node layer
1366+
gg <- gg + ggraph::geom_node_point(fill = node_colour, size = node_size)
1367+
plot@gg <- gg
1368+
plot
1369+
}
1370+
1371+
# ---- mark_chord ----
1372+
#' Chord diagram layer
1373+
#'
1374+
#' Creates a chord diagram showing pairwise relationships between groups.
1375+
#' Requires the \pkg{circlize} package. Data should be an adjacency matrix
1376+
#' or a data frame with `from`, `to`, and `value` columns.
1377+
#'
1378+
#' @param plot A plotit object
1379+
#' @param gap_width Gap between sectors in degrees (default 4).
1380+
#' @param grid_colour Colour for the outer grid (default `"grey80"`).
1381+
#' @param link_colour Colour for the chord links (default `"grey30"`).
1382+
#' @param link_alpha Alpha transparency for links (default 0.5).
1383+
#' @param ... Other arguments passed to `circlize::chordDiagram`
1384+
#' @return Modified plotit object
1385+
#' @references
1386+
#' AntV G2: \href{https://g2.antv.antgroup.com/en/api/mark/chord}{Chord} (graphlib)
1387+
#' @examples
1388+
#' \donttest{
1389+
#' if (requireNamespace("circlize", quietly = TRUE)) {
1390+
#' mat <- matrix(c(0, 5, 3, 2, 0, 4, 1, 3, 0), nrow = 3)
1391+
#' rownames(mat) <- colnames(mat) <- c("A", "B", "C")
1392+
#' plotit(as.data.frame(as.table(mat)), encode()) |>
1393+
#' mark_chord()
1394+
#' }
1395+
#' }
1396+
#' @export
1397+
mark_chord <- S7::new_generic(
1398+
"mark_chord", "plot",
1399+
function(plot, gap_width = 4, grid_colour = "grey80",
1400+
link_colour = "grey30", link_alpha = 0.5, ...) {
1401+
S7::S7_dispatch()
1402+
}
1403+
)
1404+
1405+
#' @export
1406+
S7::method(mark_chord, plotit_class) <- function(
1407+
plot, gap_width = 4, grid_colour = "grey80",
1408+
link_colour = "grey30", link_alpha = 0.5, ...) {
1409+
if (!requireNamespace("circlize", quietly = TRUE)) {
1410+
cli::cli_abort("{.fn mark_chord} requires the {.pkg circlize} package.")
1411+
}
1412+
d <- plot@gg$data
1413+
# Expect adjacency matrix or from-to-value data frame
1414+
if (is.data.frame(d)) {
1415+
if (all(c("Var1", "Var2", "Freq") %in% names(d))) {
1416+
# Convert table-as-dataframe to matrix
1417+
mat <- xtabs(Freq ~ Var1 + Var2, data = d)
1418+
} else if (all(c("from", "to", "value") %in% names(d))) {
1419+
mat <- xtabs(value ~ from + to, data = d)
1420+
} else {
1421+
cli::cli_abort(
1422+
"{.fn mark_chord} expects data with columns Var1/Var2/Freq or from/to/value."
1423+
)
1424+
}
1425+
} else if (is.matrix(d)) {
1426+
mat <- d
1427+
} else {
1428+
cli::cli_abort(
1429+
"{.fn mark_chord} expects a matrix or data frame as plot data."
1430+
)
1431+
}
1432+
# Build a new ggplot with a custom drawing layer
1433+
chord_grob <- function(...) {
1434+
circlize::chordDiagram(mat, ...)
1435+
}
1436+
# Use annotation layer since circlize draws directly
1437+
gg <- ggplot2::ggplot() + ggplot2::theme_void()
1438+
plot@gg <- gg
1439+
# Draw chord via a custom annotation
1440+
circlize::chordDiagram(mat,
1441+
transparency = 1 - link_alpha,
1442+
grid.col = grid_colour,
1443+
annotationTrack = "grid",
1444+
preAllocateTracks = list(track.height = 0.1),
1445+
...
1446+
)
1447+
plot
1448+
}
1449+
10981450
# ---- mark_bar (hand-written: geom_col vs geom_bar dispatch) ----
10991451
#' Bar layer
11001452
#'

0 commit comments

Comments
 (0)