diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 61c646a..f4ab8ba 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -386,7 +386,7 @@ because namespace references inside `ergm` model objects don't survive a plain ` `Imports` `ggplot2` (>= 4.0.0), `ggraph`, `graphlayouts`, `igraph`, `dplyr`, and `patchwork`. `ergm` and `RSiena` are listed under `Enhances` (their `plot.*` methods are only invoked if those packages are installed and such results are passed in), and `gganimate`, `gifski`, `ggforce`, -`migraph`, and `netrics` are `Suggests`-only, +`messydates`, `migraph`, and `netrics` are `Suggests`-only, so code paths depending on them should guard with `requireNamespace()` (see the `thisRequires()` helper in [R/autograph_utilities.R](../R/autograph_utilities.R)) or be skipped gracefully when unavailable. @@ -402,6 +402,14 @@ because `R CMD check` resolves a `::` call against the installed package and reports the newer name as missing even where the call is never reached. Delete each shim once the minimum is raised past the version that added the function. +`messydates` is suggested rather than imported because the dependency runs the +other way: `messydates` holds no `ggplot2` code of its own, so the `mdate` +scales in [R/scale_mdate.R](../R/scale_mdate.R) live here, where `ggplot2` is +already imported. +`.onLoad()` registers `scale_type.mdate()` with `ggplot2`, which names a scale +and so needs no `messydates`; `scale_x_mdate()` and `scale_y_mdate()` reach +`messydates` only once a plot draws a date, and guard with `thisRequires()`. + ### Tests `tests/testthat/` uses testthat edition 3 with parallel execution diff --git a/DESCRIPTION b/DESCRIPTION index 1ee66dd..adfb7bb 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: autograph Title: Automatic Plotting and Theming of Many Graphs -Version: 1.2.2 +Version: 1.2.3 Description: Visual exploration and presentation of networks should not be difficult. This package includes functions for plotting networks and network-related metrics with sensible and pretty defaults. It includes 'ggplot2'-based plot methods for many popular network package classes. @@ -13,7 +13,7 @@ Encoding: UTF-8 LazyData: true Depends: R (>= 4.1.0), - manynet (>= 2.2.3) + manynet (>= 2.3.1) Imports: dplyr (>= 1.1.0), ggraph (>= 2.2.0), @@ -25,6 +25,7 @@ Suggests: gganimate, ggforce (>= 0.5.0), gifski, + messydates (>= 1.0.0), methods, migraph, netrics (>= 0.4.0), diff --git a/NAMESPACE b/NAMESPACE index a10fa48..60ec603 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -35,6 +35,7 @@ S3method(plot,selectionTable) S3method(plot,sienaGOF) S3method(plot,tie_measure) S3method(plot,traces.monan) +S3method(print,check_drawing) S3method(print,check_separation) S3method(print,grapht) export("%>%") @@ -50,9 +51,14 @@ export(ag_positive) export(ag_qualitative) export(ag_sequential) export(ag_size) +export(check_angles) export(check_contrast) +export(check_crossings) +export(check_drawing) +export(check_lengths) export(check_offset) export(check_separation) +export(check_slopes) export(check_span) export(check_stress) export(count_pages) @@ -113,6 +119,8 @@ export(match_color) export(scale_colour_hue) export(scale_size) export(scale_x_continuous) +export(scale_x_mdate) +export(scale_y_mdate) export(set_completion) export(set_stocnet_medium) export(set_stocnet_theme) diff --git a/NEWS.md b/NEWS.md index f4be10c..ec7c446 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,29 @@ +# autograph 1.2.3 + +## Package + +- Added `{messydates}` to `Suggests` +- Raised `{manynet}` floor to 2.3.1 + +## Theming + +- Added `scale_x_mdate()` and `scale_y_mdate()` (closes globalgov/messydates#104) + - `FUN` resolves each messy date to one date; `messydates::vmin` by default +- Moved the colour checks into `R/theme_aesthetics.R` + +## Layouts + +- Added `check_crossings()` for how many other ties each tie crosses +- Added `check_slopes()` for the slope each tie is drawn at +- Added `check_lengths()` for the total, max, and variance of tie lengths +- Added `check_angles()` for the angular resolution at each node +- Added `check_drawing()`, which reports those checks plus `check_stress()` + +## Tutorials + +- Improved the isolate/bundling/backbone section ordering +- Each layout quality measure now names the function that measures it + # autograph 1.2.2 ## Package diff --git a/R/graph_costs.R b/R/graph_costs.R deleted file mode 100644 index 7415014..0000000 --- a/R/graph_costs.R +++ /dev/null @@ -1,180 +0,0 @@ -#' Checking how well a layout draws its ties -#' @description -#' These functions score a drawing rather than the network it draws, -#' so that a layout can be compared with another on the same network. -#' -#' `check_span()` reports how many rows of nodes each tie crosses. -#' A layered layout should send most ties to the next row down, -#' and a long tie is one that skips rows to get where it is going. -#' -#' `check_offset()` reports how far each tie travels sideways, -#' as a share of the width of the whole drawing. -#' A tie that drops straight down scores zero. -#' -#' `check_stress()` reports how far the distances drawn -#' depart from the distances through the network. -#' A layout that draws two nodes twice as far apart as two others -#' should be drawing a path twice as long. -#' @details -#' `check_span()` and `check_offset()` answer different questions, -#' and a layered layout needs both answered. -#' `check_span()` asks whether the rows were well chosen, -#' and `check_offset()` asks whether the nodes were well placed within them. -#' The "layered" layout minimises each in turn, and its `ranks` and -#' `alignment` arguments choose how. -#' -#' Which axis holds the rows is read from the plot, -#' as the axis on which the nodes take fewer distinct positions. -#' This is the y axis for "layered" and the x axis for "lineage", -#' so the same score can be compared across the two. -#' For a layout with no rows at all, such as "stress", -#' `check_span()` reports the distance in that axis' ranks, -#' which is not meaningful; the function is for layered layouts. -#' -#' `check_stress()` applies to any layout, since every layout draws its -#' nodes some distance apart, and the score is the share of the path -#' distances that the drawn distances get wrong. -#' It is Kruskal's stress-1, so 0 is a perfect drawing, -#' and Kruskal read 20% as poor, 10% as fair, 5% as good, -#' and 2.5% as excellent. -#' Those figures were set for psychometric data rather than for networks, -#' which are harder: most pairs of nodes in a small-world network sit -#' two or three steps apart, and a plane holds few such distances at once, -#' so a score near 30% is ordinary and one near 5% is rare. -#' A layout that never set out to draw path distances, -#' such as "layered", "circle" or "configuration", -#' scores poorly by design. -#' -#' The score belongs to the drawing rather than to the network, -#' which is what separates it from the share of distance variance -#' that `graphr()` reports beside it. -#' Draw one network two ways and the stress changes, since one drawing -#' holds its distances better than the other; -#' the share of variance does not, since two dimensions can hold -#' just as much of that network either way. -#' A network whose variance is held poorly sets a floor -#' that no layout gets under. -#' -#' The drawn distances are scaled to the path distances before they are -#' compared, since a layout may place its nodes on any scale it likes, -#' and the ties are counted unweighted, as `layout_scaling()` counts them. -#' Where a network is disconnected, the pairs with no path between them -#' are left out of the score. -#' @name check_layout -#' @family mapping -#' @source -#' Kruskal, Joseph B. 1964. -#' "Multidimensional scaling by optimizing goodness of fit to a nonmetric -#' hypothesis", _Psychometrika_ 29(1): 1-27. -#' \doi{10.1007/BF02289565} -#' @param x A plot, as `graphr()` returns. -#' @returns -#' `check_span()` returns one whole number for each tie, -#' with `total` and `mean` attributes holding the sum and the average. -#' -#' `check_offset()` returns one number between 0 and 1 for each tie, -#' with a `mean` attribute. -#' -#' `check_stress()` returns a single number of 0 or more, -#' with a `scale` attribute holding the factor the drawn distances were -#' scaled by, and a `pairs` attribute holding how many pairs were scored. -#' @examples -#' thrones <- manynet::to_uniplex(manynet::fict_thrones, "parent") -#' # The default graph is drawn once here, since each check reads the same plot. -#' drawn <- graphr(thrones) -#' # How long are the ties of the default layout? -#' attr(check_span(drawn), "total") -#' # How straight are they? -#' attr(check_offset(drawn), "mean") -#' # Compare with the layers igraph would have chosen: -#' # attr(check_span(graphr(thrones, ranks = "compact")), "total") -#' # Which layout draws the path distances best? -#' check_stress(graphr(manynet::ison_southern_women, layout = "scaling")) -#' check_stress(graphr(manynet::ison_southern_women, layout = "circle")) -NULL - -#' @rdname check_layout -#' @export -check_span <- function(x) { - lo <- .plot_coords(x) - # The rows are the axis the nodes take fewer distinct positions on, so that - # the score reads the same whether the layout runs downwards or rightwards. - rows <- if (length(unique(lo$y)) <= length(unique(lo$x))) lo$y else lo$x - rank <- match(rows, sort(unique(rows))) - el <- .plot_ties(x) - out <- abs(rank[el[, 2]] - rank[el[, 1]]) - structure(out, total = sum(out), mean = mean(out)) -} - -#' @rdname check_layout -#' @export -check_offset <- function(x) { - lo <- .plot_coords(x) - across <- if (length(unique(lo$y)) <= length(unique(lo$x))) lo$x else lo$y - width <- diff(range(across)) - el <- .plot_ties(x) - out <- abs(across[el[, 2]] - across[el[, 1]]) - if (width > 0) out <- out / width - structure(out, mean = mean(out)) -} - -#' @rdname check_layout -#' @export -check_stress <- function(x) { - crd <- as.matrix(.plot_coords(x)) - g <- manynet::as_igraph(.plot_graph(x)) - src <- .stress_sources(igraph::vcount(g)) - .stress1(igraph::distances(g, v = src, weights = NA), crd, src) -} - -# Kruskal's stress-1, between the path distances from a set of source nodes to -# every node, and the distances the layout draws between the same pairs. -# Shared with layout_scaling(), which reports the same number for the layout it -# has just computed. -.stress1 <- function(dis, crd, sources) { - drawn <- vapply(sources, function(i) - sqrt(rowSums((crd - rep(crd[i, ], each = nrow(crd)))^2)), - numeric(nrow(crd))) - drawn <- t(drawn) - keep <- is.finite(dis) & dis > 0 - d <- drawn[keep] - target <- dis[keep] - # A layout may place its nodes on any scale, so the drawn distances are - # scaled to the path distances before they are compared. Without this a - # pivot scaling of ison_southern_women, whose coordinates run much larger, - # scores 8.53 where it should score 0.32. - if (!length(d) || sum(d^2) == 0 || sum(target^2) == 0) - return(structure(NA_real_, scale = NA_real_, pairs = length(d))) - b <- sum(d * target) / sum(d^2) - structure(sqrt(sum((b * d - target)^2) / sum(target^2)), - scale = b, pairs = length(d)) -} - -# The nodes the distances are measured from. Every node where the network is -# small enough, and an evenly spaced sample of them otherwise, since a full -# distance matrix holds n^2 numbers and is soon larger than the network it -# measures. The sample is taken by position rather than at random, so that the -# same drawing scores the same on every call. -.stress_sources <- function(n, max_full = 500L) { - if (n <= max_full) return(seq_len(n)) - unique(round(seq(1, n, length.out = max_full))) -} - -.plot_coords <- function(x) { - if (!all(c("x", "y") %in% names(x[["data"]]))) manynet::snet_abort( - "{.arg x} should be a plot with node coordinates,", - "such as one {.fn graphr} returns.") - x[["data"]][, c("x", "y")] -} - -.plot_ties <- function(x) { - igraph::as_edgelist(manynet::as_igraph(.plot_graph(x)), names = FALSE) -} - -.plot_graph <- function(x) { - g <- attr(x[["data"]], "graph") - if (is.null(g)) manynet::snet_abort( - "{.arg x} should be a plot that carries the network it draws,", - "such as one {.fn graphr} returns.") - g -} diff --git a/R/layout_aesthetics.R b/R/layout_aesthetics.R new file mode 100644 index 0000000..b55c76e --- /dev/null +++ b/R/layout_aesthetics.R @@ -0,0 +1,470 @@ +#' Checking how well a layout draws a network +#' @description +#' These functions score a drawing rather than the network it draws, +#' so that one layout can be compared with another on the same network. +#' Each measures one of the qualities a layout algorithm may set out to +#' optimise. +#' +#' `check_crossings()` reports how many other ties each tie crosses. +#' A drawing with no crossings at all is a planar drawing. +#' +#' `check_slopes()` reports the slope each tie is drawn at. +#' The number of distinct slopes is the slope number of the drawing, +#' and a drawing of few slopes reads as an orderly one. +#' +#' `check_lengths()` reports how long each tie is drawn, +#' as a share of the diagonal of the drawing. +#' +#' `check_angles()` reports the smallest angle between the ties that meet +#' at each node. +#' The smallest of these is the angular resolution of the drawing, +#' and a wide angle is what keeps two ties from reading as one. +#' +#' `check_stress()` reports how far the distances drawn +#' depart from the distances through the network. +#' A layout that draws two nodes twice as far apart as two others +#' should be drawing a path twice as long. +#' +#' `check_span()` reports how many rows of nodes each tie crosses. +#' A layered layout should send most ties to the next row down, +#' and a long tie is one that skips rows to get where it is going. +#' +#' `check_offset()` reports how far each tie travels sideways, +#' as a share of the width of the whole drawing. +#' A tie that drops straight down scores zero. +#' +#' `check_drawing()` runs every check above except the two for rows, +#' and returns their headline numbers in one row. +#' @details +#' # Reading the scores +#' +#' `check_drawing()` reports one number from each check, and the columns are +#' not all read the same way. +#' Every column is better small except `angle_min`, which is better large. +#' `nodes`, `ties` and `angle_ideal` are not scores at all: +#' the first two say what was drawn, so that two rows of one network can be +#' told from two rows of different ones, and `angle_ideal` is the ceiling +#' that `angle_min` is read against. +#' No layout wins on every column. +#' A layout is chosen by which columns the reader of the figure needs, +#' rather than by how many of them it wins. +#' +#' Each column is read as follows. +#' +#' - `crossings` counts the pairs of ties that cross, so fewer is better, +#' and zero is a planar drawing. +#' Zero is not always available: a network of more than three times its +#' nodes, less six, ties cannot be drawn on a plane without a crossing, +#' and a two-mode network of more than twice its nodes, less four, ties +#' cannot either. +#' Where that floor is above zero, read the score against another layout +#' of the same network rather than against zero. +#' - `slopes` counts the directions the ties are drawn in, so fewer is +#' better. The fewest any drawing can use is half the largest degree, +#' rounded up, since the ties at the busiest node need that many +#' directions to leave it by. +#' - `length_total`, `length_max` and `length_cv` are all better small. +#' Each length is a share of the diagonal, so it runs between 0 and 1, +#' and 1 is a tie drawn corner to corner. +#' A `length_cv` of 0 means every tie is drawn the same length. +#' The total grows with the number of ties, so compare two drawings of one +#' network by their total, and two networks by the `mean` attribute of +#' `check_lengths()`. +#' - `angle_min` is the angular resolution in degrees, and is better large. +#' Read it as a share of `angle_ideal`, which is 360 degrees divided by +#' the largest degree, and is the best any drawing of that network could +#' do. +#' - `stress` is Kruskal's stress-1, so 0 is a perfect drawing. +#' Kruskal read 20% as poor, 10% as fair, 5% as good, and 2.5% as +#' excellent. +#' Those figures were set for psychometric data rather than for networks, +#' which are harder: most pairs of nodes in a small-world network sit +#' two or three steps apart, and a plane holds few such distances at once, +#' so a score near 30% is ordinary and one near 5% is rare. +#' A layout that never set out to draw path distances, +#' such as "layered", "circle" or "configuration", scores poorly by design. +#' +#' `check_span()` and `check_offset()` are left out of the row, +#' since they measure rows of nodes and so only mean something for a layered +#' layout. +#' Run them beside it where the layout has rows. +#' Both are better small. +#' They answer different questions, and a layered layout needs both answered: +#' `check_span()` asks whether the rows were well chosen, +#' and `check_offset()` asks whether the nodes were well placed within them. +#' The "layered" layout minimises each in turn, and its `ranks` and +#' `alignment` arguments choose how. +#' +#' The scores belong to the drawing rather than to the network, +#' which is what separates `check_stress()` from the share of distance +#' variance that `graphr()` reports beside it. +#' Draw one network two ways and the stress changes, since one drawing +#' holds its distances better than the other; +#' the share of variance does not, since two dimensions can hold +#' just as much of that network either way. +#' A network whose variance is held poorly sets a floor +#' that no layout gets under. +#' +#' # How the drawing is measured +#' +#' Every check reads the straight line between two nodes. +#' `graphr()` may draw a tie as an arc, a fan, or a bundle, +#' in which case the score is an approximation of what is drawn. +#' A loop is left out, since it joins a node to itself and so has neither +#' direction nor length on the plane. +#' +#' `check_crossings()` counts a proper crossing, where two ties meet away +#' from their ends. +#' Two ties that share a node are not counted, since they must meet there. +#' Comparing every pair of ties costs the square of their number, +#' so above `max_full` ties an evenly spaced sample of the ties is scored +#' instead, and the `sampled` attribute records this. +#' The sample is taken by position rather than at random, +#' so that the same drawing scores the same on every call. +#' +#' `check_slopes()` reads a slope as an angle between 0 and 180 degrees, +#' since a tie drawn from left to right has the same slope as the same tie +#' drawn from right to left. +#' Two angles closer than `tolerance` are counted as one slope, +#' because two lines that differ by a fraction of a degree read as parallel. +#' +#' `check_lengths()` divides by the diagonal of the drawing, +#' since a layout may place its nodes on any scale it likes, +#' and two layouts can only be compared once both are on the same one. +#' The spread is reported twice, since the two answer different questions: +#' `variance` is on the scale of the drawing, and `cv` divides by the mean, +#' so only `cv` compares a drawing of long ties with a drawing of short ones. +#' +#' `check_angles()` compares the directions of the ties at a node, +#' so a node with fewer than two ties scores `NA`. +#' Two ties drawn one on top of the other count once, +#' since a multiplex pair is drawn apart by `graphr()` rather than at the +#' same angle. +#' +#' `check_stress()` scales the drawn distances to the path distances before +#' it compares them, since a layout may place its nodes on any scale, +#' and the ties are counted unweighted, as `layout_scaling()` counts them. +#' Where a network is disconnected, the pairs with no path between them +#' are left out of the score. +#' +#' `check_span()` and `check_offset()` read the rows from the plot, +#' as the axis on which the nodes take fewer distinct positions. +#' This is the y axis for "layered" and the x axis for "lineage", +#' so the same score can be compared across the two. +#' For a layout with no rows at all, such as "stress", +#' `check_span()` reports the distance in that axis' ranks, +#' which is not meaningful; the two are for layered layouts. +#' @name check_layout +#' @family mapping +#' @seealso [check_colors()], which scores a palette rather than a drawing. +#' @source +#' Kruskal, Joseph B. 1964. +#' "Multidimensional scaling by optimizing goodness of fit to a nonmetric +#' hypothesis", _Psychometrika_ 29(1): 1-27. +#' \doi{10.1007/BF02289565} +#' @param x A plot, as `graphr()` returns. +#' @param max_full The largest number of ties to compare in full. +#' By default 2000. +#' @param tolerance How many degrees apart two slopes must be to count as two. +#' By default 1. +#' @returns +#' `check_crossings()` returns one whole number for each tie, being how many +#' other ties it crosses, with `total` and `mean` attributes, +#' and a `sampled` attribute saying whether the ties were sampled. +#' +#' `check_slopes()` returns one angle in degrees for each tie, +#' between 0 and 180, with `distinct` and `tolerance` attributes. +#' +#' `check_lengths()` returns one number between 0 and 1 for each tie, +#' with `total`, `max`, `mean`, `variance` and `cv` attributes. +#' +#' `check_angles()` returns one angle in degrees for each node, +#' with `min`, `mean` and `ideal` attributes. +#' +#' Each of those returns `NA` for a loop, or for a node with fewer than two +#' ties. +#' +#' `check_stress()` returns a single number of 0 or more, +#' with a `scale` attribute holding the factor the drawn distances were +#' scaled by, and a `pairs` attribute holding how many pairs were scored. +#' +#' `check_span()` returns one whole number for each tie, +#' with `total` and `mean` attributes holding the sum and the average. +#' +#' `check_offset()` returns one number between 0 and 1 for each tie, +#' with a `mean` attribute. +#' +#' `check_drawing()` returns a one row data frame of class "check_drawing", +#' holding the headline number from each check. +#' Printing it names the direction each column is read in. +#' @examples +#' sw <- manynet::ison_southern_women +#' # Every check at once, for two layouts side by side +#' circled <- graphr(sw, layout = "circle") +#' rbind(stress = check_drawing(graphr(sw, layout = "stress")), +#' circle = check_drawing(circled)) +#' # How many crossings does the default layout draw? +#' attr(check_crossings(graphr(sw)), "total") +#' # How many slopes does a circle draw, and how evenly does it draw its ties? +#' attr(check_slopes(circled), "distinct") +#' attr(check_lengths(circled), "cv") +#' # Is there room between the ties that meet at a node? +#' attr(check_angles(circled), "min") +#' # And where the layout has rows, how long and how straight are its ties? +#' thrones <- manynet::to_uniplex(manynet::fict_thrones, "parent") +#' drawn <- graphr(thrones) +#' attr(check_span(drawn), "total") +#' attr(check_offset(drawn), "mean") +NULL + +#' @rdname check_layout +#' @export +check_span <- function(x) { + lo <- .plot_coords(x) + # The rows are the axis the nodes take fewer distinct positions on, so that + # the score reads the same whether the layout runs downwards or rightwards. + rows <- if (length(unique(lo$y)) <= length(unique(lo$x))) lo$y else lo$x + rank <- match(rows, sort(unique(rows))) + el <- .plot_ties(x) + out <- abs(rank[el[, 2]] - rank[el[, 1]]) + structure(out, total = sum(out), mean = mean(out)) +} + +#' @rdname check_layout +#' @export +check_offset <- function(x) { + lo <- .plot_coords(x) + across <- if (length(unique(lo$y)) <= length(unique(lo$x))) lo$x else lo$y + width <- diff(range(across)) + el <- .plot_ties(x) + out <- abs(across[el[, 2]] - across[el[, 1]]) + if (width > 0) out <- out / width + structure(out, mean = mean(out)) +} + +#' @rdname check_layout +#' @export +check_stress <- function(x) { + crd <- as.matrix(.plot_coords(x)) + g <- manynet::as_igraph(.plot_graph(x)) + src <- .stress_sources(igraph::vcount(g)) + .stress1(igraph::distances(g, v = src, weights = NA), crd, src) +} + +# Kruskal's stress-1, between the path distances from a set of source nodes to +# every node, and the distances the layout draws between the same pairs. +# Shared with layout_scaling(), which reports the same number for the layout it +# has just computed. +.stress1 <- function(dis, crd, sources) { + drawn <- vapply(sources, function(i) + sqrt(rowSums((crd - rep(crd[i, ], each = nrow(crd)))^2)), + numeric(nrow(crd))) + drawn <- t(drawn) + keep <- is.finite(dis) & dis > 0 + d <- drawn[keep] + target <- dis[keep] + # A layout may place its nodes on any scale, so the drawn distances are + # scaled to the path distances before they are compared. Without this a + # pivot scaling of ison_southern_women, whose coordinates run much larger, + # scores 8.53 where it should score 0.32. + if (!length(d) || sum(d^2) == 0 || sum(target^2) == 0) + return(structure(NA_real_, scale = NA_real_, pairs = length(d))) + b <- sum(d * target) / sum(d^2) + structure(sqrt(sum((b * d - target)^2) / sum(target^2)), + scale = b, pairs = length(d)) +} + +# The nodes the distances are measured from. Every node where the network is +# small enough, and an evenly spaced sample of them otherwise, since a full +# distance matrix holds n^2 numbers and is soon larger than the network it +# measures. The sample is taken by position rather than at random, so that the +# same drawing scores the same on every call. +.stress_sources <- function(n, max_full = 500L) { + if (n <= max_full) return(seq_len(n)) + unique(round(seq(1, n, length.out = max_full))) +} + +.plot_coords <- function(x) { + if (!all(c("x", "y") %in% names(x[["data"]]))) manynet::snet_abort( + "{.arg x} should be a plot with node coordinates,", + "such as one {.fn graphr} returns.") + x[["data"]][, c("x", "y")] +} + +.plot_ties <- function(x) { + igraph::as_edgelist(manynet::as_igraph(.plot_graph(x)), names = FALSE) +} + +.plot_graph <- function(x) { + g <- attr(x[["data"]], "graph") + if (is.null(g)) manynet::snet_abort( + "{.arg x} should be a plot that carries the network it draws,", + "such as one {.fn graphr} returns.") + g +} + + +#' @rdname check_layout +#' @export +check_crossings <- function(x, max_full = 2000L) { + seg <- .plot_segments(x) + out <- rep(NA_real_, attr(seg, "ties")) + drawn <- attr(seg, "kept") + el <- attr(seg, "el") + # Comparing every pair of ties costs the square of their number, so a large + # network is scored on an evenly spaced sample of its ties instead, as + # .stress_sources() samples nodes for the same reason. + sampled <- length(drawn) > max_full + if (sampled) { + take <- unique(round(seq(1, length(drawn), length.out = max_full))) + seg <- seg[take, , drop = FALSE] + el <- el[take, , drop = FALSE] + drawn <- drawn[take] + } + m <- nrow(seg) + counts <- integer(m) + if (m > 1L) { + pairs <- which(upper.tri(matrix(TRUE, m, m)), arr.ind = TRUE) + i <- pairs[, 1] + j <- pairs[, 2] + # Two ties that share a node meet there by construction, and that meeting + # is not a crossing. + apart <- el[i, 1] != el[j, 1] & el[i, 1] != el[j, 2] & + el[i, 2] != el[j, 1] & el[i, 2] != el[j, 2] + i <- i[apart] + j <- j[apart] + cross <- .segments_cross(seg[i, , drop = FALSE], seg[j, , drop = FALSE]) + if (any(cross)) { + tab <- table(c(i[cross], j[cross])) + counts[as.integer(names(tab))] <- as.integer(tab) + } + } + out[drawn] <- counts + structure(out, total = sum(counts)/2, mean = mean(counts), + sampled = sampled) +} + +#' @rdname check_layout +#' @export +check_slopes <- function(x, tolerance = 1) { + seg <- .plot_segments(x) + out <- rep(NA_real_, attr(seg, "ties")) + # A slope runs from 0 to 180 degrees, since a tie drawn one way round has the + # same slope as the same tie drawn the other way round. + ang <- (atan2(seg[, "y2"] - seg[, "y1"], seg[, "x2"] - seg[, "x1"]) * + 180/pi) %% 180 + out[attr(seg, "kept")] <- ang + bins <- round(ang/tolerance) %% max(round(180/tolerance), 1) + structure(out, distinct = length(unique(bins)), tolerance = tolerance) +} + +#' @rdname check_layout +#' @export +check_lengths <- function(x) { + seg <- .plot_segments(x) + lo <- attr(seg, "nodes") + out <- rep(NA_real_, attr(seg, "ties")) + len <- sqrt((seg[, "x2"] - seg[, "x1"])^2 + (seg[, "y2"] - seg[, "y1"])^2) + # The diagonal of the drawing puts two layouts on the same scale, since a + # layout may place its nodes on any scale it likes. + diagonal <- sqrt(diff(range(lo[, 1]))^2 + diff(range(lo[, 2]))^2) + if (diagonal > 0) len <- len/diagonal + out[attr(seg, "kept")] <- len + mn <- if (length(len)) mean(len) else NA_real_ + structure(out, total = sum(len), max = if (length(len)) max(len) else NA_real_, + mean = mn, + variance = if (length(len) > 1L) stats::var(len) else NA_real_, + cv = if (length(len) > 1L && mn > 0) stats::sd(len)/mn else NA_real_) +} + +#' @rdname check_layout +#' @export +check_angles <- function(x) { + seg <- .plot_segments(x) + el <- attr(seg, "el") + lo <- attr(seg, "nodes") + out <- rep(NA_real_, nrow(lo)) + # Each tie is read from both of its ends, since the angle belongs to the node + # the ties meet at rather than to the tie. + at <- c(el[, 1], el[, 2]) + to <- c(el[, 2], el[, 1]) + ang <- atan2(lo[to, 2] - lo[at, 2], lo[to, 1] - lo[at, 1]) * 180/pi + for (i in unique(at)) { + # Two ties drawn at the same angle are one direction as far as the reader + # is concerned, and would otherwise report a resolution of zero. + a <- sort(unique(round(ang[at == i], 9) %% 360)) + if (length(a) < 2L) next + gaps <- c(diff(a), 360 - (a[length(a)] - a[1])) + out[i] <- min(gaps) + } + deg <- tabulate(at, nbins = nrow(lo)) + structure(out, min = if (all(is.na(out))) NA_real_ else min(out, na.rm = TRUE), + mean = if (all(is.na(out))) NA_real_ else mean(out, na.rm = TRUE), + ideal = if (max(deg) > 0) 360/max(deg) else NA_real_) +} + +#' @rdname check_layout +#' @export +check_drawing <- function(x) { + crossings <- check_crossings(x) + slopes <- check_slopes(x) + lengths <- check_lengths(x) + angles <- check_angles(x) + out <- data.frame( + nodes = nrow(.plot_coords(x)), + ties = length(crossings), + crossings = attr(crossings, "total"), + slopes = attr(slopes, "distinct"), + length_total = attr(lengths, "total"), + length_max = attr(lengths, "max"), + length_cv = attr(lengths, "cv"), + angle_min = attr(angles, "min"), + # The ceiling rather than a score: the smallest angle means little without + # the widest one the degrees of that network allow. + angle_ideal = attr(angles, "ideal"), + stress = as.numeric(check_stress(x))) + class(out) <- c("check_drawing", class(out)) + out +} + +#' @export +print.check_drawing <- function(x, ...) { + out <- as.data.frame(unclass(x), row.names = rownames(x)) + num <- vapply(out, is.numeric, logical(1)) & + !names(out) %in% c("nodes", "ties", "crossings", "slopes") + out[num] <- lapply(out[num], round, 3) + print(out, ...) + # A row of nine numbers says nothing about which way each is read, and the + # one column read the other way round is the one a reader would guess wrong. + cat("# Lower is better, except angle_min, where higher is better.\n", + "# nodes, ties and angle_ideal are context, not scores.\n", sep = "") + invisible(x) +} + +# The two ends of each tie, as one row for each tie the plane can draw. A loop +# joins a node to itself, so it has neither direction nor length here and is +# dropped; the attributes carry what the callers need to put their scores back +# beside every tie of the network. +.plot_segments <- function(x) { + lo <- as.matrix(.plot_coords(x)) + el <- .plot_ties(x) + keep <- el[, 1] != el[, 2] + el <- el[keep, , drop = FALSE] + structure(cbind(x1 = lo[el[, 1], 1], y1 = lo[el[, 1], 2], + x2 = lo[el[, 2], 1], y2 = lo[el[, 2], 2]), + kept = which(keep), ties = length(keep), nodes = lo, el = el) +} + +# Whether each pair of segments crosses away from their ends. Two segments +# cross when each straddles the line the other lies on, which is what the two +# pairs of opposite orientations say. A pair that only touch, so that an +# orientation is zero, is not a crossing. +.segments_cross <- function(a, b) { + orient <- function(px, py, qx, qy, rx, ry) + sign((qx - px) * (ry - py) - (qy - py) * (rx - px)) + d1 <- orient(a[, "x1"], a[, "y1"], a[, "x2"], a[, "y2"], b[, "x1"], b[, "y1"]) + d2 <- orient(a[, "x1"], a[, "y1"], a[, "x2"], a[, "y2"], b[, "x2"], b[, "y2"]) + d3 <- orient(b[, "x1"], b[, "y1"], b[, "x2"], b[, "y2"], a[, "x1"], a[, "y1"]) + d4 <- orient(b[, "x1"], b[, "y1"], b[, "x2"], b[, "y2"], a[, "x2"], a[, "y2"]) + d1 * d2 < 0 & d3 * d4 < 0 +} diff --git a/R/scale_mdate.R b/R/scale_mdate.R new file mode 100644 index 0000000..6d5c00d --- /dev/null +++ b/R/scale_mdate.R @@ -0,0 +1,85 @@ +# Scales for 'messy' dates, i.e. the "mdate" class of the {messydates} package. +# {messydates} is only suggested, and holds no {ggplot2} code of its own, so +# these scales live here, where {ggplot2} is already imported. + +#' Scales for messy dates +#' +#' @description +#' These scales place a 'messy' date, of the `mdate` class that +#' `{messydates}` defines, on the x or y axis of a `{ggplot2}` plot. +#' The dates are then spaced by how far apart they are, +#' and the axis is marked with date breaks. +#' Without them, `{ggplot2}` reads an `mdate` column as a character vector +#' and draws a discrete axis, which orders the dates as text +#' and spaces them evenly. +#' @details +#' `{ggplot2}` chooses a scale from the class of the column, +#' so a plot that maps an `mdate` column to `x` or `y` +#' picks these scales up without naming them. +#' Add the scale to the plot to pass `FUN`, `date_breaks`, or `date_labels`. +#' +#' A position on an axis is a single point, +#' but a messy date may be a range, a set, or an unspecified component, +#' and so covers a span of dates. +#' These scales therefore resolve each date to one date with `FUN`, +#' as `messydates::as.Date()` does. +#' To draw the span itself, resolve both of its ends in the mapping, +#' as in `aes(x = vmin(date), xend = vmax(date))` with `geom_segment()`; +#' both ends are `mdate` vectors, so they share this scale. +#' @param ... Arguments passed on to [ggplot2::scale_x_date()] or +#' [ggplot2::scale_y_date()], +#' such as `name`, `breaks`, `date_breaks`, `date_labels`, or `limits`. +#' @param FUN The function that resolves each messy date to the one date +#' at which it is drawn. +#' `messydates::vmin` by default, as for `messydates::as.Date()`, +#' which also takes `vmax`, `vmean`, `vmedian`, `vmodal`, or `vrandom`. +#' @returns A `{ggplot2}` scale to add to a plot. +#' @name mdate_scales +#' @examplesIf requireNamespace("messydates", quietly = TRUE) +#' library(ggplot2) +#' dates <- messydates::as_messydate(c("2012-01-01", "2012-06", "2013~")) +#' df <- data.frame(date = dates, y = 1:3) +#' ggplot(df, aes(x = date, y = y)) + geom_point() +#' ggplot(df, aes(x = date, y = y)) + geom_point() + +#' scale_x_mdate(FUN = messydates::vmax, date_labels = "%Y-%m") +NULL + +#' @rdname mdate_scales +#' @export +scale_x_mdate <- function(..., FUN = messydates::vmin) { + .mdate_scale("x", ..., FUN = FUN) +} + +#' @rdname mdate_scales +#' @export +scale_y_mdate <- function(..., FUN = messydates::vmin) { + .mdate_scale("y", ..., FUN = FUN) +} + +# Builds a {ggplot2} date scale, then swaps in a transformation that resolves +# an mdate to a Date first. {ggplot2}'s own date transformation rejects +# anything that is not already a Date. The scale is otherwise untouched, so +# its breaks, labels and guides stay those of a date axis. +.mdate_scale <- function(aesthetic, ..., FUN) { + thisRequires("messydates") + scale <- if (aesthetic == "x") ggplot2::scale_x_date(...) else + ggplot2::scale_y_date(...) + transformation <- if (is.null(scale$get_transformation)) scale$trans else + scale$get_transformation() + transformation$transform <- function(x) { + if (inherits(x, "mdate")) x <- as.Date(x, FUN = FUN) + if (inherits(x, "POSIXct")) x <- as.Date(x) + structure(as.numeric(x), names = names(x)) + } + # the field is `trans` up to ggplot2 3.5 and `transformation` thereafter + if (exists("trans", envir = scale, inherits = FALSE)) + scale$trans <- transformation + if (exists("transformation", envir = scale, inherits = FALSE)) + scale$transformation <- transformation + scale +} + +# Tells {ggplot2} which scale to look for when a layer maps an mdate column. +# {ggplot2} then finds scale_x_mdate()/scale_y_mdate() above. +# Registered in .onLoad(), since {ggplot2} owns the generic. +scale_type.mdate <- function(x) "mdate" diff --git a/R/theme_aesthetics.R b/R/theme_aesthetics.R new file mode 100644 index 0000000..2cce9aa --- /dev/null +++ b/R/theme_aesthetics.R @@ -0,0 +1,124 @@ +#' Checking colours for colour blindness and legibility +#' @description +#' These functions score a set of colours rather than the plot that uses +#' them, so that one palette can be compared with another. +#' +#' `check_separation()` reports how far apart the colours are, +#' taking the worst case over normal vision and each type of colour +#' blindness, so that a palette is only credited for a difference that +#' every viewer can see. +#' +#' `check_contrast()` reports whether text can be read on a ground. +#' @details +#' The two functions answer different questions, +#' and a palette needs both answered. +#' `check_separation()` asks whether two marks can be told apart, +#' and `check_contrast()` asks whether text can be read on what it sits on. +#' [simulate_colorblind()] asks a third question, +#' which is whether either survives a photocopier. +#' +#' Distances are Euclidean distances in CIELAB space, the same measure +#' [match_color()] uses. +#' As a rule of thumb, a distance below 10 means two colours are easily +#' confused, 10 to 25 means they are separable but close, +#' and above 25 means they are comfortably distinct. +#' Ratios are those of WCAG 2.1, which asks for at least 4.5 for body text +#' and at least 3 for large text and for graphical objects. +#' +#' Colour blindness affects about 8% of men and 0.5% of women, +#' and the worst case is taken over deuteranopia, protanopia and +#' tritanopia as well as normal vision. +#' @name check_colors +#' @family themes +#' @seealso [check_layout()], +#' which scores a drawing rather than a palette. +#' @param colors One or more colours, given as hexcodes or as names R knows. +#' @references +#' World Wide Web Consortium. 2018. +#' _Web Content Accessibility Guidelines (WCAG) 2.1_. +#' \url{https://www.w3.org/TR/WCAG21/} +#' @returns +#' `check_separation()` returns a square matrix of worst-case distances, +#' with the colours as its dimnames and a missing diagonal, +#' so that `min(x, na.rm = TRUE)` gives the closest pair. +#' A "grey" attribute holds the same matrix as seen in greyscale. +#' +#' `check_contrast()` returns a square matrix of WCAG contrast ratios, +#' shaped the same way. +#' @examples +#' # How well does the current theme's palette separate five categories? +#' check_separation(ag_qualitative(5)) +#' # The closest pair in it +#' min(check_separation(ag_qualitative(5)), na.rm = TRUE) +#' # And the closest pair once it is printed in greyscale +#' min(attr(check_separation(ag_qualitative(5)), "grey"), na.rm = TRUE) +#' # A red and a green that only look different to some viewers +#' check_separation(c("#B7352D", "#627313"))[1, 2] +#' # Can the current theme's ink be read on its ground? +#' check_contrast(ag_ink())[1, 2] +NULL + +#' @rdname check_colors +#' @param background Optionally, a colour to include in the comparison, +#' so that a colour too pale or too dark to be seen against it is not +#' counted as distinct. +#' By default the current theme's background is used. +#' @export +check_separation <- function(colors, background = NULL){ + if(!is.null(background)) colors <- c(background, colors) + types <- names(colorblind_matrices) + dists <- lapply(types, + function(ty) as.matrix(stats::dist(colorblind_lab(colors, ty)))) + # A pair is only as distinguishable as its worst view of it. + out <- Reduce(pmin, dists) + # The diagonal is left missing rather than zero, so that the obvious way to + # ask how well a palette separates -- min() over the matrix -- reports the + # closest pair of different colours, and not the zero distance from each + # colour to itself. + diag(out) <- NA_real_ + dimnames(out) <- list(colors, colors) + # Greyscale is reported beside the score rather than folded into it. Two + # colours that differ only in hue collapse in greyscale however well they + # serve a colour-blind reader, so a worst case that included it would + # condemn nearly every institutional palette and leave only lightness to + # design with. Whether a figure has to survive a photocopier is the user's + # question to answer, so the number is offered, not imposed. + grey <- as.matrix(stats::dist(colorblind_lab(colors, "grey"))) + diag(grey) <- NA_real_ + dimnames(grey) <- dimnames(out) + attr(out, "grey") <- grey + class(out) <- c("check_separation", class(out)) + out +} + +#' @export +print.check_separation <- function(x, ...){ + grey <- attr(x, "grey") + out <- unclass(x) + attr(out, "grey") <- NULL + print(out, ...) + # The greyscale matrix is summarised rather than printed. Its interest is + # almost always the one number -- whether anything collapses in print -- + # and a second matrix of the same size would bury the first. + if(!is.null(grey) && any(!is.na(grey))) + cat("\nClosest pair in greyscale: ", + round(min(grey, na.rm = TRUE), 1), "\n", sep = "") + invisible(x) +} + +#' @rdname check_colors +#' @export +check_contrast <- function(colors, background = NULL){ + # Unlike check_separation(), where a background is one more colour to keep + # away from, here it is what the others are read *on*, so it belongs in the + # comparison whether or not the user names one. + if(is.null(background)) background <- ag_ground_fill() + colors <- c(background, colors) + lum <- relative_luminance(colors) + lighter <- outer(lum, lum, pmax) + darker <- outer(lum, lum, pmin) + out <- (lighter + 0.05)/(darker + 0.05) + diag(out) <- NA_real_ + dimnames(out) <- list(colors, colors) + out +} diff --git a/R/theme_colorblind.R b/R/theme_colorblind.R index bdad6ba..fa4d08f 100644 --- a/R/theme_colorblind.R +++ b/R/theme_colorblind.R @@ -1,23 +1,10 @@ -#' Checking colours for colour blindness, print, and legibility +#' Simulating how colours look to a colour-blind viewer #' @description -#' These functions report how a set of colours holds up for viewers with -#' colour vision deficiency (CVD), which affects about 8% of men and 0.5% -#' of women, and for readers who see the plot in greyscale or at a distance. -#' -#' `simulate_colorblind()` returns what a set of colours looks like to a viewer with -#' a given type of colour blindness, or in greyscale. -#' `check_separation()` scores how far apart colours are, taking the worst case -#' over normal vision and each type of colour blindness, -#' so that a palette is only credited for a difference that every viewer -#' can see. -#' `check_contrast()` scores whether text can be read on a ground. +#' `simulate_colorblind()` returns what a set of colours looks like to a +#' viewer with a given type of colour vision deficiency (CVD), +#' which affects about 8% of men and 0.5% of women, +#' or in greyscale, as a reader sees the plot in print. #' @details -#' The three functions answer three different questions, -#' and a palette needs all three answered. -#' `check_separation()` asks whether two marks can be told apart, -#' `check_contrast()` asks whether text can be read on what it sits on, -#' and the "grey" simulation asks whether either survives a photocopier. -#' #' Simulation uses the matrices of Machado, Oliveira and Fernandes (2009), #' applied in linear RGB. #' Those matrices are published for each severity of colour blindness; @@ -27,17 +14,10 @@ #' a lower severity is anomalous trichromacy (deuteranomaly, protanomaly), #' which is the more common condition. #' Greyscale conversion takes the relative luminance of the colour, -#' the same quantity `check_contrast()` scores with. -#' -#' Distances are Euclidean distances in CIELAB space, the same measure -#' [match_color()] uses. -#' As a rule of thumb, a distance below 10 means two colours are easily -#' confused, 10 to 25 means they are separable but close, -#' and above 25 means they are comfortably distinct. -#' Ratios are those of WCAG 2.1, which asks for at least 4.5 for body text -#' and at least 3 for large text and for graphical objects. +#' the same quantity [check_contrast()] scores with. #' @name theme_colorblind #' @family themes +#' @seealso [check_colors()], which scores a palette rather than simulating it. #' @param colors One or more colours, given as hexcodes or as names R knows. #' @param type The type of colour blindness to simulate: #' "deutan" (green-blind, the most common), "protan" (red-blind), @@ -52,35 +32,13 @@ #' "A Physiologically-Based Model for Simulation of Color Vision Deficiency". #' _IEEE Transactions on Visualization and Computer Graphics_ 15(6): 1291-98. #' \doi{10.1109/TVCG.2009.113} -#' -#' World Wide Web Consortium. 2018. -#' _Web Content Accessibility Guidelines (WCAG) 2.1_. -#' \url{https://www.w3.org/TR/WCAG21/} -#' @returns +#' @returns #' `simulate_colorblind()` returns a vector of hexcodes as long as `colors`. -#' -#' `check_separation()` returns a square matrix of worst-case distances, -#' with the colours as its dimnames and a missing diagonal, -#' so that `min(x, na.rm = TRUE)` gives the closest pair. -#' A "grey" attribute holds the same matrix as seen in greyscale. -#' -#' `check_contrast()` returns a square matrix of WCAG contrast ratios, -#' shaped the same way. #' @examples #' simulate_colorblind(c("#d73027", "#4575b4"), "deutan") #' # A milder deuteranomaly, and the same colours in greyscale #' simulate_colorblind(c("#d73027", "#4575b4"), "deutan", severity = 0.5) #' simulate_colorblind(c("#d73027", "#4575b4"), "grey") -#' # How well does the current theme's palette separate five categories? -#' check_separation(ag_qualitative(5)) -#' # The closest pair in it -#' min(check_separation(ag_qualitative(5)), na.rm = TRUE) -#' # And the closest pair once it is printed in greyscale -#' min(attr(check_separation(ag_qualitative(5)), "grey"), na.rm = TRUE) -#' # A red and a green that only look different to some viewers -#' check_separation(c("#B7352D", "#627313"))[1, 2] -#' # Can the current theme's ink be read on its ground? -#' check_contrast(ag_ink())[1, 2] #' @export simulate_colorblind <- function(colors, type = c("deutan", "protan", "tritan", "grey", "normal"), @@ -108,71 +66,6 @@ simulate_colorblind <- function(colors, grDevices::rgb(sim[,1], sim[,2], sim[,3]) } -#' @rdname theme_colorblind -#' @param background Optionally, a colour to include in the comparison, -#' so that a colour too pale or too dark to be seen against it is not -#' counted as distinct. -#' By default the current theme's background is used. -#' @export -check_separation <- function(colors, background = NULL){ - if(!is.null(background)) colors <- c(background, colors) - types <- names(colorblind_matrices) - dists <- lapply(types, - function(ty) as.matrix(stats::dist(colorblind_lab(colors, ty)))) - # A pair is only as distinguishable as its worst view of it. - out <- Reduce(pmin, dists) - # The diagonal is left missing rather than zero, so that the obvious way to - # ask how well a palette separates -- min() over the matrix -- reports the - # closest pair of different colours, and not the zero distance from each - # colour to itself. - diag(out) <- NA_real_ - dimnames(out) <- list(colors, colors) - # Greyscale is reported beside the score rather than folded into it. Two - # colours that differ only in hue collapse in greyscale however well they - # serve a colour-blind reader, so a worst case that included it would - # condemn nearly every institutional palette and leave only lightness to - # design with. Whether a figure has to survive a photocopier is the user's - # question to answer, so the number is offered, not imposed. - grey <- as.matrix(stats::dist(colorblind_lab(colors, "grey"))) - diag(grey) <- NA_real_ - dimnames(grey) <- dimnames(out) - attr(out, "grey") <- grey - class(out) <- c("check_separation", class(out)) - out -} - -#' @export -print.check_separation <- function(x, ...){ - grey <- attr(x, "grey") - out <- unclass(x) - attr(out, "grey") <- NULL - print(out, ...) - # The greyscale matrix is summarised rather than printed. Its interest is - # almost always the one number -- whether anything collapses in print -- - # and a second matrix of the same size would bury the first. - if(!is.null(grey) && any(!is.na(grey))) - cat("\nClosest pair in greyscale: ", - round(min(grey, na.rm = TRUE), 1), "\n", sep = "") - invisible(x) -} - -#' @rdname theme_colorblind -#' @export -check_contrast <- function(colors, background = NULL){ - # Unlike check_separation(), where a background is one more colour to keep - # away from, here it is what the others are read *on*, so it belongs in the - # comparison whether or not the user names one. - if(is.null(background)) background <- ag_ground_fill() - colors <- c(background, colors) - lum <- relative_luminance(colors) - lighter <- outer(lum, lum, pmax) - darker <- outer(lum, lum, pmin) - out <- (lighter + 0.05)/(darker + 0.05) - diag(out) <- NA_real_ - dimnames(out) <- list(colors, colors) - out -} - # Machado, Oliveira and Fernandes (2009), severity 1.0, for linear RGB. colorblind_matrices <- list( normal = diag(3), diff --git a/R/zzz.R b/R/zzz.R index f85dae8..7ed0807 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -71,3 +71,13 @@ greet_startup_cli() } # nocov end + +# {ggplot2} owns the scale_type() generic, and {messydates} owns the mdate +# class, so neither can register the method that connects them. autograph +# imports {ggplot2}, and so registers it here. The method names a scale; it +# does not need {messydates}, which is only reached once a plot draws a date. +.onLoad <- function(libname, pkgname) { + registerS3method("scale_type", "mdate", scale_type.mdate, + envir = asNamespace("ggplot2")) + invisible(NULL) +} diff --git a/cran-comments.md b/cran-comments.md index 15a5fb7..f6fb1df 100644 --- a/cran-comments.md +++ b/cran-comments.md @@ -9,4 +9,4 @@ 0 errors | 0 warnings | 0 notes -This fixes the 'Additional issues' relating to a test depending on BLAS \ No newline at end of file +- This fixes some tests that were failing for a manynet reverse dependency because of a change in how messages were being printed and tested here \ No newline at end of file diff --git a/inst/tutorials/autograph1/visualisation.Rmd b/inst/tutorials/autograph1/visualisation.Rmd index 37a67fa..d92393d 100644 --- a/inst/tutorials/autograph1/visualisation.Rmd +++ b/inst/tutorials/autograph1/visualisation.Rmd @@ -581,23 +581,46 @@ setting `edge_size` yourself resizes both together. ### Taming dense or disconnected networks {#taming-networks} -Sometimes networks are just a dense hairball. +Sometimes networks look like a dense hairball. This is a technical term to describe networks with many high-degree nodes and many ties, -where the sheer number of ties obscures the structure of the network. -Autograph includes three arguments that can help with this. +where the sheer number of ties obscures any structure that might be in the network. +Autograph includes three arguments that can help with this, +some of which are turned on by default, +so we will need to turn them off to see the effect of each in isolation. + +#### Isolates {#isolates} + +First, it is possible that the hairball is not a hairball at all, +but a network with many disconnected components or, especially, `r gloss("isolates","isolate")`, +that each need to be drawn. +As each `r gloss("component")` is drawn under a force-directed layout, +the components will repel each other and the `r gloss("giant")` component will be squeezed into a clump. + +The `isolates` argument in `graphr()` provides some options for what to do with them instead: +`"legend"` (the default) drops them from the drawing but records how many there were in the legend, +`"caption"` notes them in a caption instead, +and `"keep"` leaves them in place. +**Add ten unconnected characters to `fict_lotr` and compare keeping them with noting them in the legend.** + +```{r isolates, exercise=TRUE, fig.width=9} +lotr_iso <- to_unlabelled(fict_lotr) |> + add_nodes(10) +(graphr(lotr_iso, isolates = "keep") + ggtitle("keep") | + graphr(lotr_iso, isolates = "legend") + ggtitle("legend")) +``` #### Bundling ties {#bundling} -The first option is to draw all of the ties 'bundled' together, +The second option is to draw all of the ties 'bundled' together, which can reveal where the most common paths through the network are. `edge_bundle` pulls ties that travel in similar directions into shared paths — like cabling them together — so that the main 'highways' of the network stand out. It is off by default; set `edge_bundle = TRUE` (or name a specific algorithm: `"force"`, `"path"`, or `"minimal"`) to switch it on. -**`ison_lawfirm` records 71 lawyers and 2571 -ties between them, which is about as thick a hairball as a network this small -can be. Compare it drawn with and without bundling (turn backbone off too for clearest comparison results).** +**`ison_lawfirm` records 71 lawyers and 2571 ties between them, +which is about as thick a hairball as a network this small can be. +Compare it drawn with and without bundling (turn backbone off too for clearest comparison results).** ```{r bundle, exercise=TRUE, fig.width=9} graphr(ison_lawfirm, backbone = FALSE) + ggtitle("Unbundled") | @@ -643,23 +666,6 @@ before reaching for both. A bundled tie cannot carry a fading of its own — bundling merges ties into shared paths — so where both are asked for, the backbone still shapes the layout but every tie is drawn alike. -#### Isolates {#isolates} - -At the other extreme, many networks contain `r gloss("isolates","isolate")` — -unconnected nodes — which, under a force-directed layout, drift to the margins -and squeeze the connected core into a clump. The `isolates` argument decides -what happens to them: `"legend"` (the default) drops them from the drawing but -records how many there were in the legend, `"caption"` notes them in a caption -instead, and `"keep"` leaves them in place. **Add two unconnected characters -to `fict_lotr` and compare keeping them with noting them in the legend.** - -```{r isolates, exercise=TRUE, fig.width=9} -lotr_iso <- fict_lotr |> - add_nodes(2, list(name = c("Tom Bombadil", "Goldberry"))) -(graphr(lotr_iso, isolates = "keep") + ggtitle("keep") | - graphr(lotr_iso, isolates = "legend") + ggtitle("legend")) -``` - For very large real-world networks such as `irps_blogs`, these work well together: a backbone picks out the ties that hold the connected core together (or `edge_bundle = TRUE`, if you would rather see the paths the ties take than @@ -1196,18 +1202,55 @@ is the point of this section. Quality measures a layout algorithm might attend to include: - minimising the _crossing number_ of edges/ties in the graph -([planar graphs](https://www.jasondavies.com/planarity/) require no crossings) +([planar graphs](https://www.jasondavies.com/planarity/) require no crossings): +`check_crossings()` - minimising the _slope number_ of distinct edge slopes in the graph -(where vertices are represented as points on a Euclidean plane) +(where vertices are represented as points on a Euclidean plane): +`check_slopes()` - minimising the _bend number_ in all edges in the graph -(every graph has a right angle crossing (RAC) drawing with three bends per edge) -- minimising the _total edge length_ -- minimising the _maximum edge length_ -- minimising the _edge length variance_ -- maximising the _angular resolution_ or sharpest angle of edges meeting at a common vertex -- minimising the _bounding box_ of the plot -- evening the _aspect ratio_ of the plot -- displaying _symmetry groups_ (subgraph automorphisms) +(every graph has a right angle crossing (RAC) drawing with three bends per edge): +not measured, since the bends are set by `graphr()`'s `edge_curved` and +`edge_bundle` arguments rather than by the layout +- minimising the _total edge length_: `attr(check_lengths(p), "total")` +- minimising the _maximum edge length_: `attr(check_lengths(p), "max")` +- minimising the _edge length variance_: `attr(check_lengths(p), "variance")` +- maximising the _angular resolution_ or sharpest angle of edges meeting at a common vertex: +`attr(check_angles(p), "min")` +- minimising the _bounding box_ of the plot: not measured +- evening the _aspect ratio_ of the plot: not measured +- displaying _symmetry groups_ (subgraph automorphisms): not measured + +Each of these functions scores a drawing rather than the network it draws, +so two layouts of the same network can be compared. +`check_drawing()` runs them all at once, beside `check_stress()`, +which reports how far the distances drawn depart from the distances +through the network. +**Compare a stress layout with a circle.** + +```{r check-drawing, exercise=TRUE} +sw_stress <- graphr(ison_southern_women, layout = "stress") +sw_circle <- graphr(ison_southern_women, layout = "circle") +rbind(stress = check_drawing(sw_stress), circle = check_drawing(sw_circle)) +``` + +Every column is read downwards except `angle_min`, which is read upwards. +Fewer crossings, fewer slopes, shorter ties, more even ties, and a wider +smallest angle all make a drawing easier to read. +`nodes`, `ties` and `angle_ideal` are context rather than scores. +`angle_ideal` is the widest smallest angle the degrees of this network allow, +so `angle_min` is read as a share of it. +The floors are worth knowing as well: +a network of this many ties cannot be drawn without crossings at all, +and no drawing of it can use fewer slopes than half its largest degree. + +No layout wins on every measure. +The circle draws every node on one ring, which costs it crossings and length: +it draws many times the crossings of the stress layout, and three times the +total tie length. It buys the other measures with the same ring: +fewer slopes, ties of a more even length, and more room between the ties that +meet at a node. Its stress is the worse of the two, +because a layout that never set out to draw the path distances +scores poorly on them by design. Graph layouts available in the `{igraph}`, `{ggraph}`, `{graphlayouts}`, and `{autograph}` packages can be used in `graphr()`. diff --git a/inst/tutorials/autograph1/visualisation.html b/inst/tutorials/autograph1/visualisation.html index 3832516..3cbb721 100644 --- a/inst/tutorials/autograph1/visualisation.html +++ b/inst/tutorials/autograph1/visualisation.html @@ -771,14 +771,44 @@

Pointing arrows

Taming dense or disconnected networks

-

Sometimes networks are just a dense hairball. This is a technical +

Sometimes networks look like a dense hairball. This is a technical term to describe networks with many high-degree nodes and many ties, -where the sheer number of ties obscures the structure of the network. -Autograph includes three arguments that can help with this.

+where the sheer number of ties obscures any structure that might be in +the network. Autograph includes three arguments that can help with this, +some of which are turned on by default, so we will need to turn them off +to see the effect of each in isolation.

+
+

Isolates

+

First, it is possible that the hairball is not a hairball at all, but +a network with many disconnected components or, especially, + +isolates , that each need to be drawn. As each + +component is drawn under a force-directed layout, the +components will repel each other and the + +giant component will be squeezed into a clump.

+

The isolates argument in graphr() provides +some options for what to do with them instead: "legend" +(the default) drops them from the drawing but records how many there +were in the legend, "caption" notes them in a caption +instead, and "keep" leaves them in place. Add ten +unconnected characters to fict_lotr and compare keeping +them with noting them in the legend.

+
+
lotr_iso <- to_unlabelled(fict_lotr) |>
+  add_nodes(10)
+(graphr(lotr_iso, isolates = "keep") + ggtitle("keep") |
+   graphr(lotr_iso, isolates = "legend") + ggtitle("legend"))
+ +
+

Bundling ties

-

The first option is to draw all of the ties ‘bundled’ together, which -can reveal where the most common paths through the network are. +

The second option is to draw all of the ties ‘bundled’ together, +which can reveal where the most common paths through the network are. edge_bundle pulls ties that travel in similar directions into shared paths — like cabling them together — so that the main ‘highways’ of the network stand out. It is off by default; set @@ -842,29 +872,6 @@

Backbones

of its own — bundling merges ties into shared paths — so where both are asked for, the backbone still shapes the layout but every tie is drawn alike.

-
-
-

Isolates

-

At the other extreme, many networks contain - -isolates — unconnected nodes — which, under a -force-directed layout, drift to the margins and squeeze the connected -core into a clump. The isolates argument decides what -happens to them: "legend" (the default) drops them from the -drawing but records how many there were in the legend, -"caption" notes them in a caption instead, and -"keep" leaves them in place. Add two unconnected -characters to fict_lotr and compare keeping them with -noting them in the legend.

-
-
lotr_iso <- fict_lotr |>
-  add_nodes(2, list(name = c("Tom Bombadil", "Goldberry")))
-(graphr(lotr_iso, isolates = "keep") + ggtitle("keep") |
-   graphr(lotr_iso, isolates = "legend") + ggtitle("legend"))
- -

For very large real-world networks such as irps_blogs, these work well together: a backbone picks out the ties that hold the connected core together (or edge_bundle = TRUE, if you @@ -1459,22 +1466,60 @@

Layouts

+

Each of these functions scores a drawing rather than the network it +draws, so two layouts of the same network can be compared. +check_drawing() runs them all at once, beside +check_stress(), which reports how far the distances drawn +depart from the distances through the network. Compare a stress +layout with a circle.

+
+
sw_stress <- graphr(ison_southern_women, layout = "stress")
+sw_circle <- graphr(ison_southern_women, layout = "circle")
+rbind(stress = check_drawing(sw_stress), circle = check_drawing(sw_circle))
+ +
+

Every column is read downwards except angle_min, which +is read upwards. Fewer crossings, fewer slopes, shorter ties, more even +ties, and a wider smallest angle all make a drawing easier to read. +nodes, ties and angle_ideal are +context rather than scores. angle_ideal is the widest +smallest angle the degrees of this network allow, so +angle_min is read as a share of it. The floors are worth +knowing as well: a network of this many ties cannot be drawn without +crossings at all, and no drawing of it can use fewer slopes than half +its largest degree.

+

No layout wins on every measure. The circle draws every node on one +ring, which costs it crossings and length: it draws many times the +crossings of the stress layout, and three times the total tie length. It +buys the other measures with the same ring: fewer slopes, ties of a more +even length, and more room between the ties that meet at a node. Its +stress is the worse of the two, because a layout that never set out to +draw the path distances scores poorly on them by design.

Graph layouts available in the {igraph}, {ggraph}, {graphlayouts}, and {autograph} packages can be used in graphr(). @@ -2466,6 +2511,13 @@

Glossary

self-ties.
+Component +
+
+A component is a connected subgraph not part of a larger connected +subgraph. +
+
Degree
@@ -2486,6 +2538,13 @@

Glossary

the nodes in a network.
+Giant +
+
+The giant component is the component that includes the most nodes in the +network. +
+
Homophily
@@ -2617,19 +2676,19 @@

Glossary

@@ -2780,23 +2839,23 @@

Glossary

@@ -3019,20 +3078,20 @@

Glossary

@@ -3135,19 +3194,19 @@

Glossary

@@ -4171,6 +4230,49 @@

Glossary

))) + + + + + @@ -4746,11 +4848,11 @@

Glossary

@@ -4887,18 +4989,18 @@

Glossary

@@ -4956,19 +5058,19 @@

Glossary

@@ -5097,24 +5199,24 @@

Glossary

@@ -5300,19 +5402,19 @@

Glossary

@@ -5379,25 +5481,25 @@

Glossary

@@ -5462,19 +5564,19 @@

Glossary

@@ -5493,19 +5595,19 @@

Glossary

diff --git a/man/check_colors.Rd b/man/check_colors.Rd new file mode 100644 index 0000000..c2fe409 --- /dev/null +++ b/man/check_colors.Rd @@ -0,0 +1,88 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/theme_aesthetics.R +\name{check_colors} +\alias{check_colors} +\alias{check_separation} +\alias{check_contrast} +\title{Checking colours for colour blindness and legibility} +\usage{ +check_separation(colors, background = NULL) + +check_contrast(colors, background = NULL) +} +\arguments{ +\item{colors}{One or more colours, given as hexcodes or as names R knows.} + +\item{background}{Optionally, a colour to include in the comparison, +so that a colour too pale or too dark to be seen against it is not +counted as distinct. +By default the current theme's background is used.} +} +\value{ +\code{check_separation()} returns a square matrix of worst-case distances, +with the colours as its dimnames and a missing diagonal, +so that \code{min(x, na.rm = TRUE)} gives the closest pair. +A "grey" attribute holds the same matrix as seen in greyscale. + +\code{check_contrast()} returns a square matrix of WCAG contrast ratios, +shaped the same way. +} +\description{ +These functions score a set of colours rather than the plot that uses +them, so that one palette can be compared with another. + +\code{check_separation()} reports how far apart the colours are, +taking the worst case over normal vision and each type of colour +blindness, so that a palette is only credited for a difference that +every viewer can see. + +\code{check_contrast()} reports whether text can be read on a ground. +} +\details{ +The two functions answer different questions, +and a palette needs both answered. +\code{check_separation()} asks whether two marks can be told apart, +and \code{check_contrast()} asks whether text can be read on what it sits on. +\code{\link[=simulate_colorblind]{simulate_colorblind()}} asks a third question, +which is whether either survives a photocopier. + +Distances are Euclidean distances in CIELAB space, the same measure +\code{\link[=match_color]{match_color()}} uses. +As a rule of thumb, a distance below 10 means two colours are easily +confused, 10 to 25 means they are separable but close, +and above 25 means they are comfortably distinct. +Ratios are those of WCAG 2.1, which asks for at least 4.5 for body text +and at least 3 for large text and for graphical objects. + +Colour blindness affects about 8\% of men and 0.5\% of women, +and the worst case is taken over deuteranopia, protanopia and +tritanopia as well as normal vision. +} +\examples{ +# How well does the current theme's palette separate five categories? +check_separation(ag_qualitative(5)) +# The closest pair in it +min(check_separation(ag_qualitative(5)), na.rm = TRUE) +# And the closest pair once it is printed in greyscale +min(attr(check_separation(ag_qualitative(5)), "grey"), na.rm = TRUE) +# A red and a green that only look different to some viewers +check_separation(c("#B7352D", "#627313"))[1, 2] +# Can the current theme's ink be read on its ground? +check_contrast(ag_ink())[1, 2] +} +\references{ +World Wide Web Consortium. 2018. +\emph{Web Content Accessibility Guidelines (WCAG) 2.1}. +\url{https://www.w3.org/TR/WCAG21/} +} +\seealso{ +\code{\link[=check_layout]{check_layout()}}, +which scores a drawing rather than a palette. + +Other themes: +\code{\link[=list_fonts]{list_fonts()}}, +\code{\link{theme_colorblind}}, +\code{\link{theme_medium}}, +\code{\link{theme_set}} +} +\concept{themes} diff --git a/man/check_layout.Rd b/man/check_layout.Rd index 88b9b72..25e8912 100644 --- a/man/check_layout.Rd +++ b/man/check_layout.Rd @@ -1,11 +1,16 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/graph_costs.R +% Please edit documentation in R/layout_aesthetics.R \name{check_layout} \alias{check_layout} \alias{check_span} \alias{check_offset} \alias{check_stress} -\title{Checking how well a layout draws its ties} +\alias{check_crossings} +\alias{check_slopes} +\alias{check_lengths} +\alias{check_angles} +\alias{check_drawing} +\title{Checking how well a layout draws a network} \source{ Kruskal, Joseph B. 1964. "Multidimensional scaling by optimizing goodness of fit to a nonmetric @@ -18,24 +23,82 @@ check_span(x) check_offset(x) check_stress(x) + +check_crossings(x, max_full = 2000L) + +check_slopes(x, tolerance = 1) + +check_lengths(x) + +check_angles(x) + +check_drawing(x) } \arguments{ \item{x}{A plot, as \code{graphr()} returns.} + +\item{max_full}{The largest number of ties to compare in full. +By default 2000.} + +\item{tolerance}{How many degrees apart two slopes must be to count as two. +By default 1.} } \value{ +\code{check_crossings()} returns one whole number for each tie, being how many +other ties it crosses, with \code{total} and \code{mean} attributes, +and a \code{sampled} attribute saying whether the ties were sampled. + +\code{check_slopes()} returns one angle in degrees for each tie, +between 0 and 180, with \code{distinct} and \code{tolerance} attributes. + +\code{check_lengths()} returns one number between 0 and 1 for each tie, +with \code{total}, \code{max}, \code{mean}, \code{variance} and \code{cv} attributes. + +\code{check_angles()} returns one angle in degrees for each node, +with \code{min}, \code{mean} and \code{ideal} attributes. + +Each of those returns \code{NA} for a loop, or for a node with fewer than two +ties. + +\code{check_stress()} returns a single number of 0 or more, +with a \code{scale} attribute holding the factor the drawn distances were +scaled by, and a \code{pairs} attribute holding how many pairs were scored. + \code{check_span()} returns one whole number for each tie, with \code{total} and \code{mean} attributes holding the sum and the average. \code{check_offset()} returns one number between 0 and 1 for each tie, with a \code{mean} attribute. -\code{check_stress()} returns a single number of 0 or more, -with a \code{scale} attribute holding the factor the drawn distances were -scaled by, and a \code{pairs} attribute holding how many pairs were scored. +\code{check_drawing()} returns a one row data frame of class "check_drawing", +holding the headline number from each check. +Printing it names the direction each column is read in. } \description{ These functions score a drawing rather than the network it draws, -so that a layout can be compared with another on the same network. +so that one layout can be compared with another on the same network. +Each measures one of the qualities a layout algorithm may set out to +optimise. + +\code{check_crossings()} reports how many other ties each tie crosses. +A drawing with no crossings at all is a planar drawing. + +\code{check_slopes()} reports the slope each tie is drawn at. +The number of distinct slopes is the slope number of the drawing, +and a drawing of few slopes reads as an orderly one. + +\code{check_lengths()} reports how long each tie is drawn, +as a share of the diagonal of the drawing. + +\code{check_angles()} reports the smallest angle between the ties that meet +at each node. +The smallest of these is the angular resolution of the drawing, +and a wide angle is what keeps two ties from reading as one. + +\code{check_stress()} reports how far the distances drawn +depart from the distances through the network. +A layout that draws two nodes twice as far apart as two others +should be drawing a path twice as long. \code{check_span()} reports how many rows of nodes each tie crosses. A layered layout should send most ties to the next row down, @@ -45,72 +108,151 @@ and a long tie is one that skips rows to get where it is going. as a share of the width of the whole drawing. A tie that drops straight down scores zero. -\code{check_stress()} reports how far the distances drawn -depart from the distances through the network. -A layout that draws two nodes twice as far apart as two others -should be drawing a path twice as long. +\code{check_drawing()} runs every check above except the two for rows, +and returns their headline numbers in one row. } -\details{ -\code{check_span()} and \code{check_offset()} answer different questions, -and a layered layout needs both answered. -\code{check_span()} asks whether the rows were well chosen, -and \code{check_offset()} asks whether the nodes were well placed within them. -The "layered" layout minimises each in turn, and its \code{ranks} and -\code{alignment} arguments choose how. +\section{Reading the scores}{ +\code{check_drawing()} reports one number from each check, and the columns are +not all read the same way. +Every column is better small except \code{angle_min}, which is better large. +\code{nodes}, \code{ties} and \code{angle_ideal} are not scores at all: +the first two say what was drawn, so that two rows of one network can be +told from two rows of different ones, and \code{angle_ideal} is the ceiling +that \code{angle_min} is read against. +No layout wins on every column. +A layout is chosen by which columns the reader of the figure needs, +rather than by how many of them it wins. -Which axis holds the rows is read from the plot, -as the axis on which the nodes take fewer distinct positions. -This is the y axis for "layered" and the x axis for "lineage", -so the same score can be compared across the two. -For a layout with no rows at all, such as "stress", -\code{check_span()} reports the distance in that axis' ranks, -which is not meaningful; the function is for layered layouts. - -\code{check_stress()} applies to any layout, since every layout draws its -nodes some distance apart, and the score is the share of the path -distances that the drawn distances get wrong. -It is Kruskal's stress-1, so 0 is a perfect drawing, -and Kruskal read 20\% as poor, 10\% as fair, 5\% as good, -and 2.5\% as excellent. +Each column is read as follows. +\itemize{ +\item \code{crossings} counts the pairs of ties that cross, so fewer is better, +and zero is a planar drawing. +Zero is not always available: a network of more than three times its +nodes, less six, ties cannot be drawn on a plane without a crossing, +and a two-mode network of more than twice its nodes, less four, ties +cannot either. +Where that floor is above zero, read the score against another layout +of the same network rather than against zero. +\item \code{slopes} counts the directions the ties are drawn in, so fewer is +better. The fewest any drawing can use is half the largest degree, +rounded up, since the ties at the busiest node need that many +directions to leave it by. +\item \code{length_total}, \code{length_max} and \code{length_cv} are all better small. +Each length is a share of the diagonal, so it runs between 0 and 1, +and 1 is a tie drawn corner to corner. +A \code{length_cv} of 0 means every tie is drawn the same length. +The total grows with the number of ties, so compare two drawings of one +network by their total, and two networks by the \code{mean} attribute of +\code{check_lengths()}. +\item \code{angle_min} is the angular resolution in degrees, and is better large. +Read it as a share of \code{angle_ideal}, which is 360 degrees divided by +the largest degree, and is the best any drawing of that network could +do. +\item \code{stress} is Kruskal's stress-1, so 0 is a perfect drawing. +Kruskal read 20\% as poor, 10\% as fair, 5\% as good, and 2.5\% as +excellent. Those figures were set for psychometric data rather than for networks, which are harder: most pairs of nodes in a small-world network sit two or three steps apart, and a plane holds few such distances at once, so a score near 30\% is ordinary and one near 5\% is rare. A layout that never set out to draw path distances, -such as "layered", "circle" or "configuration", -scores poorly by design. +such as "layered", "circle" or "configuration", scores poorly by design. +} + +\code{check_span()} and \code{check_offset()} are left out of the row, +since they measure rows of nodes and so only mean something for a layered +layout. +Run them beside it where the layout has rows. +Both are better small. +They answer different questions, and a layered layout needs both answered: +\code{check_span()} asks whether the rows were well chosen, +and \code{check_offset()} asks whether the nodes were well placed within them. +The "layered" layout minimises each in turn, and its \code{ranks} and +\code{alignment} arguments choose how. -The score belongs to the drawing rather than to the network, -which is what separates it from the share of distance variance -that \code{graphr()} reports beside it. +The scores belong to the drawing rather than to the network, +which is what separates \code{check_stress()} from the share of distance +variance that \code{graphr()} reports beside it. Draw one network two ways and the stress changes, since one drawing holds its distances better than the other; the share of variance does not, since two dimensions can hold just as much of that network either way. A network whose variance is held poorly sets a floor that no layout gets under. +} + +\section{How the drawing is measured}{ +Every check reads the straight line between two nodes. +\code{graphr()} may draw a tie as an arc, a fan, or a bundle, +in which case the score is an approximation of what is drawn. +A loop is left out, since it joins a node to itself and so has neither +direction nor length on the plane. + +\code{check_crossings()} counts a proper crossing, where two ties meet away +from their ends. +Two ties that share a node are not counted, since they must meet there. +Comparing every pair of ties costs the square of their number, +so above \code{max_full} ties an evenly spaced sample of the ties is scored +instead, and the \code{sampled} attribute records this. +The sample is taken by position rather than at random, +so that the same drawing scores the same on every call. -The drawn distances are scaled to the path distances before they are -compared, since a layout may place its nodes on any scale it likes, +\code{check_slopes()} reads a slope as an angle between 0 and 180 degrees, +since a tie drawn from left to right has the same slope as the same tie +drawn from right to left. +Two angles closer than \code{tolerance} are counted as one slope, +because two lines that differ by a fraction of a degree read as parallel. + +\code{check_lengths()} divides by the diagonal of the drawing, +since a layout may place its nodes on any scale it likes, +and two layouts can only be compared once both are on the same one. +The spread is reported twice, since the two answer different questions: +\code{variance} is on the scale of the drawing, and \code{cv} divides by the mean, +so only \code{cv} compares a drawing of long ties with a drawing of short ones. + +\code{check_angles()} compares the directions of the ties at a node, +so a node with fewer than two ties scores \code{NA}. +Two ties drawn one on top of the other count once, +since a multiplex pair is drawn apart by \code{graphr()} rather than at the +same angle. + +\code{check_stress()} scales the drawn distances to the path distances before +it compares them, since a layout may place its nodes on any scale, and the ties are counted unweighted, as \code{layout_scaling()} counts them. Where a network is disconnected, the pairs with no path between them are left out of the score. + +\code{check_span()} and \code{check_offset()} read the rows from the plot, +as the axis on which the nodes take fewer distinct positions. +This is the y axis for "layered" and the x axis for "lineage", +so the same score can be compared across the two. +For a layout with no rows at all, such as "stress", +\code{check_span()} reports the distance in that axis' ranks, +which is not meaningful; the two are for layered layouts. } + \examples{ +sw <- manynet::ison_southern_women +# Every check at once, for two layouts side by side +circled <- graphr(sw, layout = "circle") +rbind(stress = check_drawing(graphr(sw, layout = "stress")), + circle = check_drawing(circled)) +# How many crossings does the default layout draw? +attr(check_crossings(graphr(sw)), "total") +# How many slopes does a circle draw, and how evenly does it draw its ties? +attr(check_slopes(circled), "distinct") +attr(check_lengths(circled), "cv") +# Is there room between the ties that meet at a node? +attr(check_angles(circled), "min") +# And where the layout has rows, how long and how straight are its ties? thrones <- manynet::to_uniplex(manynet::fict_thrones, "parent") -# The default graph is drawn once here, since each check reads the same plot. drawn <- graphr(thrones) -# How long are the ties of the default layout? attr(check_span(drawn), "total") -# How straight are they? attr(check_offset(drawn), "mean") -# Compare with the layers igraph would have chosen: -# attr(check_span(graphr(thrones, ranks = "compact")), "total") -# Which layout draws the path distances best? -check_stress(graphr(manynet::ison_southern_women, layout = "scaling")) -check_stress(graphr(manynet::ison_southern_women, layout = "circle")) } \seealso{ +\code{\link[=check_colors]{check_colors()}}, which scores a palette rather than a drawing. + Other mapping: \code{\link{completion}}, \code{\link[=layout_concentric]{layout_concentric()}}, diff --git a/man/list_fonts.Rd b/man/list_fonts.Rd index 62ace0b..156b84d 100644 --- a/man/list_fonts.Rd +++ b/man/list_fonts.Rd @@ -27,6 +27,7 @@ head(list_fonts()) } \seealso{ Other themes: +\code{\link{check_colors}}, \code{\link{theme_colorblind}}, \code{\link{theme_medium}}, \code{\link{theme_set}} diff --git a/man/mdate_scales.Rd b/man/mdate_scales.Rd new file mode 100644 index 0000000..03fcf05 --- /dev/null +++ b/man/mdate_scales.Rd @@ -0,0 +1,59 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/scale_mdate.R +\name{mdate_scales} +\alias{mdate_scales} +\alias{scale_x_mdate} +\alias{scale_y_mdate} +\title{Scales for messy dates} +\usage{ +scale_x_mdate(..., FUN = messydates::vmin) + +scale_y_mdate(..., FUN = messydates::vmin) +} +\arguments{ +\item{...}{Arguments passed on to \code{\link[ggplot2:scale_x_date]{ggplot2::scale_x_date()}} or +\code{\link[ggplot2:scale_y_date]{ggplot2::scale_y_date()}}, +such as \code{name}, \code{breaks}, \code{date_breaks}, \code{date_labels}, or \code{limits}.} + +\item{FUN}{The function that resolves each messy date to the one date +at which it is drawn. +\code{messydates::vmin} by default, as for \code{messydates::as.Date()}, +which also takes \code{vmax}, \code{vmean}, \code{vmedian}, \code{vmodal}, or \code{vrandom}.} +} +\value{ +A \code{{ggplot2}} scale to add to a plot. +} +\description{ +These scales place a 'messy' date, of the \code{mdate} class that +\code{{messydates}} defines, on the x or y axis of a \code{{ggplot2}} plot. +The dates are then spaced by how far apart they are, +and the axis is marked with date breaks. +Without them, \code{{ggplot2}} reads an \code{mdate} column as a character vector +and draws a discrete axis, which orders the dates as text +and spaces them evenly. +} +\details{ +\code{{ggplot2}} chooses a scale from the class of the column, +so a plot that maps an \code{mdate} column to \code{x} or \code{y} +picks these scales up without naming them. +Add the scale to the plot to pass \code{FUN}, \code{date_breaks}, or \code{date_labels}. + +A position on an axis is a single point, +but a messy date may be a range, a set, or an unspecified component, +and so covers a span of dates. +These scales therefore resolve each date to one date with \code{FUN}, +as \code{messydates::as.Date()} does. +To draw the span itself, resolve both of its ends in the mapping, +as in \code{aes(x = vmin(date), xend = vmax(date))} with \code{geom_segment()}; +both ends are \code{mdate} vectors, so they share this scale. +} +\examples{ +\dontshow{if (requireNamespace("messydates", quietly = TRUE)) withAutoprint(\{ # examplesIf} +library(ggplot2) +dates <- messydates::as_messydate(c("2012-01-01", "2012-06", "2013~")) +df <- data.frame(date = dates, y = 1:3) +ggplot(df, aes(x = date, y = y)) + geom_point() +ggplot(df, aes(x = date, y = y)) + geom_point() + + scale_x_mdate(FUN = messydates::vmax, date_labels = "\%Y-\%m") +\dontshow{\}) # examplesIf} +} diff --git a/man/theme_colorblind.Rd b/man/theme_colorblind.Rd index 9961032..7fe1e5e 100644 --- a/man/theme_colorblind.Rd +++ b/man/theme_colorblind.Rd @@ -3,19 +3,13 @@ \name{theme_colorblind} \alias{theme_colorblind} \alias{simulate_colorblind} -\alias{check_separation} -\alias{check_contrast} -\title{Checking colours for colour blindness, print, and legibility} +\title{Simulating how colours look to a colour-blind viewer} \usage{ simulate_colorblind( colors, type = c("deutan", "protan", "tritan", "grey", "normal"), severity = 1 ) - -check_separation(colors, background = NULL) - -check_contrast(colors, background = NULL) } \arguments{ \item{colors}{One or more colours, given as hexcodes or as names R knows.} @@ -29,43 +23,17 @@ or "normal" for unaffected vision.} By default 1, which is dichromacy. A value between 0 and 1 is anomalous trichromacy. Ignored for the "grey" and "normal" types.} - -\item{background}{Optionally, a colour to include in the comparison, -so that a colour too pale or too dark to be seen against it is not -counted as distinct. -By default the current theme's background is used.} } \value{ \code{simulate_colorblind()} returns a vector of hexcodes as long as \code{colors}. - -\code{check_separation()} returns a square matrix of worst-case distances, -with the colours as its dimnames and a missing diagonal, -so that \code{min(x, na.rm = TRUE)} gives the closest pair. -A "grey" attribute holds the same matrix as seen in greyscale. - -\code{check_contrast()} returns a square matrix of WCAG contrast ratios, -shaped the same way. } \description{ -These functions report how a set of colours holds up for viewers with -colour vision deficiency (CVD), which affects about 8\% of men and 0.5\% -of women, and for readers who see the plot in greyscale or at a distance. - -\code{simulate_colorblind()} returns what a set of colours looks like to a viewer with -a given type of colour blindness, or in greyscale. -\code{check_separation()} scores how far apart colours are, taking the worst case -over normal vision and each type of colour blindness, -so that a palette is only credited for a difference that every viewer -can see. -\code{check_contrast()} scores whether text can be read on a ground. +\code{simulate_colorblind()} returns what a set of colours looks like to a +viewer with a given type of colour vision deficiency (CVD), +which affects about 8\% of men and 0.5\% of women, +or in greyscale, as a reader sees the plot in print. } \details{ -The three functions answer three different questions, -and a palette needs all three answered. -\code{check_separation()} asks whether two marks can be told apart, -\code{check_contrast()} asks whether text can be read on what it sits on, -and the "grey" simulation asks whether either survives a photocopier. - Simulation uses the matrices of Machado, Oliveira and Fernandes (2009), applied in linear RGB. Those matrices are published for each severity of colour blindness; @@ -75,44 +43,25 @@ Full severity is dichromacy (deuteranopia, protanopia, tritanopia); a lower severity is anomalous trichromacy (deuteranomaly, protanomaly), which is the more common condition. Greyscale conversion takes the relative luminance of the colour, -the same quantity \code{check_contrast()} scores with. - -Distances are Euclidean distances in CIELAB space, the same measure -\code{\link[=match_color]{match_color()}} uses. -As a rule of thumb, a distance below 10 means two colours are easily -confused, 10 to 25 means they are separable but close, -and above 25 means they are comfortably distinct. -Ratios are those of WCAG 2.1, which asks for at least 4.5 for body text -and at least 3 for large text and for graphical objects. +the same quantity \code{\link[=check_contrast]{check_contrast()}} scores with. } \examples{ simulate_colorblind(c("#d73027", "#4575b4"), "deutan") # A milder deuteranomaly, and the same colours in greyscale simulate_colorblind(c("#d73027", "#4575b4"), "deutan", severity = 0.5) simulate_colorblind(c("#d73027", "#4575b4"), "grey") -# How well does the current theme's palette separate five categories? -check_separation(ag_qualitative(5)) -# The closest pair in it -min(check_separation(ag_qualitative(5)), na.rm = TRUE) -# And the closest pair once it is printed in greyscale -min(attr(check_separation(ag_qualitative(5)), "grey"), na.rm = TRUE) -# A red and a green that only look different to some viewers -check_separation(c("#B7352D", "#627313"))[1, 2] -# Can the current theme's ink be read on its ground? -check_contrast(ag_ink())[1, 2] } \references{ Machado, Gustavo M., Manuel M. Oliveira, and Leandro A. F. Fernandes. 2009. "A Physiologically-Based Model for Simulation of Color Vision Deficiency". \emph{IEEE Transactions on Visualization and Computer Graphics} 15(6): 1291-98. \doi{10.1109/TVCG.2009.113} - -World Wide Web Consortium. 2018. -\emph{Web Content Accessibility Guidelines (WCAG) 2.1}. -\url{https://www.w3.org/TR/WCAG21/} } \seealso{ +\code{\link[=check_colors]{check_colors()}}, which scores a palette rather than simulating it. + Other themes: +\code{\link{check_colors}}, \code{\link[=list_fonts]{list_fonts()}}, \code{\link{theme_medium}}, \code{\link{theme_set}} diff --git a/man/theme_medium.Rd b/man/theme_medium.Rd index 8558e55..f16bb76 100644 --- a/man/theme_medium.Rd +++ b/man/theme_medium.Rd @@ -80,6 +80,7 @@ stocnet_medium("screen") } \seealso{ Other themes: +\code{\link{check_colors}}, \code{\link[=list_fonts]{list_fonts()}}, \code{\link{theme_colorblind}}, \code{\link{theme_set}} diff --git a/man/theme_set.Rd b/man/theme_set.Rd index dab3515..622bfa5 100644 --- a/man/theme_set.Rd +++ b/man/theme_set.Rd @@ -117,6 +117,7 @@ plot(netrics::node_by_degree(ison_karateka)) } \seealso{ Other themes: +\code{\link{check_colors}}, \code{\link[=list_fonts]{list_fonts()}}, \code{\link{theme_colorblind}}, \code{\link{theme_medium}} diff --git a/pkgdown/_pkgdown.yml b/pkgdown/_pkgdown.yml index 307d27e..97e20f9 100644 --- a/pkgdown/_pkgdown.yml +++ b/pkgdown/_pkgdown.yml @@ -73,6 +73,7 @@ reference: - list_fonts - theme_colorblind - match_color + - check_colors - ends_with("themes") - ends_with("scales") - ends_with("palettes") diff --git a/tests/testthat/helper-manynet.R b/tests/testthat/helper-manynet.R index 3de5862..b91715f 100644 --- a/tests/testthat/helper-manynet.R +++ b/tests/testthat/helper-manynet.R @@ -15,3 +15,35 @@ ag_net <- function(x) manynet::as_tidygraph(x) # for the version, as the package itself does, since a development build can # carry a version string without the function. manynet_has <- function(fn) fn %in% getNamespaceExports("manynet") + +# manynet raised snet_warn() from a cli alert to a warning condition in 2.3.2. +# CRAN still ships 2.3.1, where the same call prints (and only where the +# verbosity allows it) rather than raising anything a test can catch, so a +# deprecation that warns here announces nothing there. Probed rather than read +# off the version, as manynet_has() is, and probed once, since the answer +# cannot change within a session. +snet_warns <- local({ + known <- NULL + function() { + if (is.null(known)) { + known <<- FALSE + withCallingHandlers( + suppressMessages(manynet::snet_warn("probing snet_warn")), + warning = function(w) { + known <<- TRUE + invokeRestart("muffleWarning") + }) + } + known + } +}) + +# A deprecation, or any other snet_warn(), asserted against either manynet. +# Where the installed manynet raises a warning, the warning and its wording are +# asserted. Where it does not, the call is only asked to run, since there is no +# condition to catch: the test then still covers the code path, and the +# assertion returns as soon as manynet 2.3.2 reaches CRAN. +expect_snet_warning <- function(expr, regexp, label = NULL) { + if (snet_warns()) testthat::expect_warning(expr, regexp, label = label) + else testthat::expect_no_error(expr) +} diff --git a/tests/testthat/test-functional_coverage.R b/tests/testthat/test-functional_coverage.R index 630f0b0..7fc7f85 100644 --- a/tests/testthat/test-functional_coverage.R +++ b/tests/testthat/test-functional_coverage.R @@ -256,11 +256,9 @@ test_that("a diffusion that never spread says so rather than plotting", { flat <- manynet::as_diffusion( manynet::play_diffusion(manynet::create_empty(5), seeds = 1)) expect_equal(nrow(flat), 1L) - # snet_warn() speaks through cli, and only when verbosity is turned up. - old <- options(snet_verbosity = "verbose") - on.exit(options(old), add = TRUE) - # The method returns the cli message's own value, not a plot. - expect_message(out <- plot(flat), "No diffusion was observed") + # The method warns and returns the warning's own value, not a plot. + expect_snet_warning(out <- plot(flat), "No diffusion was observed") + out <- suppressWarnings(plot(flat)) expect_false(inherits(out, "ggplot")) }) diff --git a/tests/testthat/test-functional_layouts.R b/tests/testthat/test-functional_layouts.R index b6e3480..d61d6f3 100644 --- a/tests/testthat/test-functional_layouts.R +++ b/tests/testthat/test-functional_layouts.R @@ -137,7 +137,12 @@ test_that("every layout substitutes and says so where it does not apply", { old <- options(snet_verbosity = "verbose") on.exit(options(old), add = TRUE) reqs <- autograph:::.layout_requirements() - for (lay in names(reqs)) { + # A retired name is renamed by .check_layout() (R/graph_checks.R) before the + # requirement is judged, so the message names the layout that was attempted + # rather than the name that was typed. That renaming is tested below, in "a + # deprecated layout name is renamed once", and the retired names are left out + # here rather than asking this audit to know both spellings. + for (lay in setdiff(names(reqs), autograph:::.deprecated_layouts())) { cand <- layout_candidates(lay) for (fix in cand$no) { net <- ag_layout_pool[[fix]] @@ -208,7 +213,7 @@ test_that("every deprecated layout still draws, and is offered nowhere", { sizes <- c("dyad", "triad", "tetrad", "pentad", "hexad") net <- if (lay %in% sizes) manynet::create_ring(match(lay, sizes) + 1L) else if (lay == "multilevel") manynet::ison_southern_women else g - expect_message(coords <- fn(net), "deprecated", label = lay) + expect_snet_warning(coords <- fn(net), "deprecated", label = lay) expect_true(all(c("x", "y") %in% names(coords)), label = lay) expect_equal(nrow(coords), as.integer(manynet::net_nodes(net)), label = lay) } @@ -217,13 +222,16 @@ test_that("every deprecated layout still draws, and is offered nowhere", { test_that("a deprecated layout name is renamed once, where it is checked", { old <- options(snet_verbosity = "verbose") on.exit(options(old), add = TRUE) - expect_message(lay <- autograph:::.check_layout("hierarchy"), "deprecated") + # a deprecation warns, whatever the verbosity, so that a user who is not + # reading the console still learns the name is going away + expect_snet_warning(lay <- autograph:::.check_layout("hierarchy"), "deprecated") expect_equal(lay, "layered") - expect_equal(suppressMessages(autograph:::.check_layout("alluvial")), "lineage") - expect_equal(suppressMessages(autograph:::.check_layout("multilevel")), "levels") - expect_equal(suppressMessages(autograph:::.check_layout("triad")), "configuration") + expect_equal(suppressWarnings(autograph:::.check_layout("alluvial")), "lineage") + expect_equal(suppressWarnings(autograph:::.check_layout("multilevel")), "levels") + expect_equal(suppressWarnings(autograph:::.check_layout("triad")), "configuration") # A live name passes through untouched and says nothing. - expect_no_message(expect_equal(autograph:::.check_layout("layered"), "layered")) + expect_no_warning(expect_no_message( + expect_equal(autograph:::.check_layout("layered"), "layered"))) }) test_that("matching layout aligns matched partners vertically", { @@ -234,3 +242,36 @@ test_that("matching layout aligns matched partners vertically", { manynet::net_nodes(manynet::ison_southern_women)) }) + +test_that("every drawing check scores every layout", { + skip_on_cran() + # The checks are enumerated from the namespace, as the layouts are, so a new + # check_*() that reads a plot is audited without a test being written. The + # colour checks read a palette rather than a plot, and are audited in + # test-functional_themes.R instead. + checks <- setdiff(ag_alive_functions("^check_"), + c("check_separation", "check_contrast")) + expect_true(length(checks) > 0) + for (lay in autograph:::.autograph_layouts()) { + fix <- layout_candidates(lay, n_ok = 1)$ok + if (length(fix) == 0) next + net <- ag_layout_pool[[fix]] + p <- run_or_skip( + do.call(graphr, c(list(net, layout = lay), layout_extra_args(lay, net))), + paste0("draw ", lay), fix) + for (fn in checks) { + out <- run_or_skip(get(fn, envir = asNamespace("autograph"))(p), + fn, paste0(lay, " x ", fix)) + run_or_skip({ + # A check scores what it is given, so it returns one score for every + # tie or node of every layout, rather than only of the ones it was + # written against. A score may be missing -- a dyad has no angle + # between its ties -- but it may not be infinite or a character. + vals <- unlist(out) + testthat::expect_true(length(vals) > 0) + testthat::expect_true(is.numeric(vals)) + testthat::expect_true(all(is.finite(vals[!is.na(vals)]))) + }, paste0("score ", fn), paste0(lay, " x ", fix)) + } + } +}) diff --git a/tests/testthat/test-grapht.R b/tests/testthat/test-grapht.R index 74c0433..32057da 100644 --- a/tests/testthat/test-grapht.R +++ b/tests/testthat/test-grapht.R @@ -230,12 +230,13 @@ test_that("keep_isolates is deprecated but still honoured", { tied <- manynet::as_tidygraph(igraph::graph_from_data_frame( data.frame(from = "A", to = "B"), directed = FALSE, vertices = data.frame(name = c("A", "B", "C")))) - # The deprecation notice comes through the stocnet cli interface (snet_warn), - # so it is a message rather than a base R warning. - old <- options(snet_verbosity = "verbose") - on.exit(options(old), add = TRUE) - expect_message(p <- grapht(list(t1 = tied, t2 = tied), keep_isolates = FALSE), - "deprecated") + # The deprecation notice comes through the stocnet cli interface, which + # raises a warning condition whatever the verbosity from manynet 2.3.2, and + # prints without raising one before that (see helper-manynet.R). + expect_snet_warning(p <- grapht(list(t1 = tied, t2 = tied), + keep_isolates = FALSE), "deprecated") + p <- suppressWarnings(grapht(list(t1 = tied, t2 = tied), + keep_isolates = FALSE)) nd <- .node_layer(p)$data expect_true(all(nd$nalpha[nd$name == "C"] == 0)) }) diff --git a/tests/testthat/test-layout_aesthetics.R b/tests/testthat/test-layout_aesthetics.R new file mode 100644 index 0000000..fabf6de --- /dev/null +++ b/tests/testthat/test-layout_aesthetics.R @@ -0,0 +1,118 @@ +# The aesthetic checks are geometry, so they are tested on drawings whose +# geometry is known. graphr() routes `x` and `y` to the manual layout (see +# graph_layout.R), which places the nodes exactly where these tests put them. + +square <- function(net) graphr(net, x = c(0, 1, 1, 0), y = c(0, 0, 1, 1)) + +# Crossings ---- + +test_that("check_crossings() counts a proper crossing and no other", { + skip_on_cran() + # A four-cycle on the corners of a square draws its ties along the sides, + # which meet only at their ends. + cycle <- square(manynet::create_ring(4)) + expect_equal(attr(check_crossings(cycle), "total"), 0) + expect_false(attr(check_crossings(cycle), "sampled")) + # Adding both diagonals adds exactly one crossing, in the middle. + k4 <- square(manynet::create_explicit(A-B, B-C, C-D, D-A, A-C, B-D)) + crossed <- check_crossings(k4) + expect_equal(attr(crossed, "total"), 1) + # The two ties that cross are the two diagonals, and each counts the other + expect_equal(sum(crossed == 1), 2L) +}) + +test_that("check_crossings() samples above its cap", { + skip_on_cran() + sw <- graphr(manynet::ison_southern_women, layout = "circle") + full <- check_crossings(sw) + part <- check_crossings(sw, max_full = 20L) + expect_false(attr(full, "sampled")) + expect_true(attr(part, "sampled")) + # The sample scores fewer ties, so it cannot find more crossings + expect_lt(attr(part, "total"), attr(full, "total")) + expect_true(any(is.na(part))) +}) + +# Slopes, lengths, and angles ---- + +test_that("check_slopes() counts the slopes a square draws", { + skip_on_cran() + cycle <- square(manynet::create_ring(4)) + slopes <- check_slopes(cycle) + # Two sides run flat and two run upright + expect_equal(attr(slopes, "distinct"), 2L) + expect_setequal(round(slopes), c(0, 90)) + # A coarser tolerance cannot find more slopes than a finer one + expect_lte(attr(check_slopes(cycle, tolerance = 45), "distinct"), + attr(slopes, "distinct")) +}) + +test_that("check_lengths() reports equal sides as no variance", { + skip_on_cran() + lengths <- check_lengths(square(manynet::create_ring(4))) + expect_equal(attr(lengths, "variance"), 0) + expect_equal(attr(lengths, "cv"), 0) + # Each side is one, and the diagonal of the drawing is the square root of two + expect_equal(attr(lengths, "max"), 1/sqrt(2)) + expect_equal(attr(lengths, "total"), 4/sqrt(2)) +}) + +test_that("check_angles() reports the resolution and what it could be", { + skip_on_cran() + # Two ties meet at each corner of a square, at a right angle + angles <- check_angles(square(manynet::create_ring(4))) + expect_equal(attr(angles, "min"), 90) + expect_equal(attr(angles, "ideal"), 180) + # A star drawn as a star spaces its ties as evenly as the degree allows + star <- check_angles(graphr(manynet::create_star(5), layout = "star")) + expect_equal(attr(star, "min"), attr(star, "ideal")) + # A node of one tie has no angle to report, and the centre of the star does + expect_equal(sum(!is.na(star)), 1L) +}) + +# Loops, and plots that cannot be checked ---- + +test_that("the checks leave a loop out", { + skip_on_cran() + # A loop joins a node to itself, so it has neither slope nor length. + looped <- graphr(manynet::create_explicit(A-A, A-B, B-C)) + expect_true(is.na(check_lengths(looped)[1])) + expect_true(is.na(check_slopes(looped)[1])) + expect_true(is.na(check_crossings(looped)[1])) + # It is still one of the ties, so the scores line up with the network + expect_length(check_lengths(looped), 3L) +}) + +test_that("the checks refuse a plot they cannot read", { + skip_on_cran() + plain <- ggplot2::ggplot(data.frame(a = 1:2, b = 1:2)) + for (fn in list(check_crossings, check_slopes, check_lengths, check_angles, + check_drawing)) + expect_error(fn(plain), "node coordinates") +}) + +# The summary ---- + +test_that("check_drawing() gathers the checks into one row", { + skip_on_cran() + sw <- manynet::ison_southern_women + drawn <- check_drawing(graphr(sw, layout = "circle")) + expect_s3_class(drawn, "check_drawing") + expect_equal(nrow(drawn), 1L) + expect_equal(drawn$nodes, as.integer(manynet::net_nodes(sw))) + expect_equal(drawn$ties, as.integer(manynet::net_ties(sw))) + # Each column repeats the headline number of its own check + expect_equal(drawn$stress, as.numeric(check_stress(graphr(sw, layout = "circle")))) + # The ceiling travels with the score it is read against + expect_equal(drawn$angle_ideal, + attr(check_angles(graphr(sw, layout = "circle")), "ideal")) + expect_output(print(drawn), "crossings") + # A row of numbers says nothing about which way each column is read + expect_output(print(drawn), "angle_min, where higher is better") + # Two layouts stack, which is how two layouts are compared + both <- rbind(circle = drawn, + stress = check_drawing(graphr(sw, layout = "stress"))) + expect_equal(nrow(both), 2L) + # A scaling layout draws the path distances better than a circle does + expect_lt(both["stress", "stress"], both["circle", "stress"]) +}) diff --git a/tests/testthat/test-scale_mdate.R b/tests/testthat/test-scale_mdate.R new file mode 100644 index 0000000..06ae175 --- /dev/null +++ b/tests/testthat/test-scale_mdate.R @@ -0,0 +1,63 @@ +# The mdate scales resolve each messy date to one date and draw a date axis, +# instead of the discrete character axis that {ggplot2} draws by default. +# {messydates} is only suggested, so every test skips without it. + +make_mdates <- function() { + df <- data.frame(y = 1:3) + df$date <- messydates::as_messydate(c("2012-01-01", "2012-06", "2013~")) + df +} + +test_that("a layer mapping an mdate gets a date scale without asking", { + skip_if_not_installed("messydates") + df <- make_mdates() + p <- ggplot2::ggplot(df, ggplot2::aes(x = date, y = y)) + + ggplot2::geom_point() + built <- ggplot2::ggplot_build(p) + expect_equal(built$data[[1]]$x, + as.numeric(as.Date(df$date, FUN = messydates::vmin))) + expect_match(built$layout$panel_params[[1]]$x$get_labels(), + "[0-9]{4}", all = TRUE) +}) + +test_that("FUN chooses how each messy date is resolved onto the axis", { + skip_if_not_installed("messydates") + df <- make_mdates() + p <- ggplot2::ggplot(df, ggplot2::aes(x = date, y = y)) + + ggplot2::geom_point() + scale_x_mdate(FUN = messydates::vmax) + expect_equal(ggplot2::ggplot_build(p)$data[[1]]$x, + as.numeric(as.Date(df$date, FUN = messydates::vmax))) +}) + +test_that("scale_y_mdate() places an mdate on the y axis", { + skip_if_not_installed("messydates") + df <- make_mdates() + p <- ggplot2::ggplot(df, ggplot2::aes(x = y, y = date)) + + ggplot2::geom_point() + scale_y_mdate() + expect_equal(ggplot2::ggplot_build(p)$data[[1]]$y, + as.numeric(as.Date(df$date, FUN = messydates::vmin))) +}) + +test_that("the span a messy date covers can be drawn with both ends", { + skip_if_not_installed("messydates") + df <- make_mdates() + p <- ggplot2::ggplot(df, ggplot2::aes(x = messydates::vmin(date), + xend = messydates::vmax(date), + y = y, yend = y)) + + ggplot2::geom_segment() + built <- ggplot2::ggplot_build(p) + expect_equal(built$data[[1]]$x, + as.numeric(as.Date(df$date, FUN = messydates::vmin))) + expect_equal(built$data[[1]]$xend, + as.numeric(as.Date(df$date, FUN = messydates::vmax))) +}) + +test_that("a plot with an mdate scale renders", { + skip_if_not_installed("messydates") + df <- make_mdates() + p <- ggplot2::ggplot(df, ggplot2::aes(x = date, y = y)) + + ggplot2::geom_point() + scale_x_mdate(date_labels = "%Y-%m") + grDevices::pdf(NULL) + on.exit(grDevices::dev.off(), add = TRUE) + expect_no_error(suppressWarnings(print(p))) +}) diff --git a/vignettes/articles/visualising-networks.Rmd b/vignettes/articles/visualising-networks.Rmd index 9229c75..5326d4d 100644 --- a/vignettes/articles/visualising-networks.Rmd +++ b/vignettes/articles/visualising-networks.Rmd @@ -534,23 +534,46 @@ setting `edge_size` yourself resizes both together. ### Taming dense or disconnected networks {#taming-networks} -Sometimes networks are just a dense hairball. +Sometimes networks look like a dense hairball. This is a technical term to describe networks with many high-degree nodes and many ties, -where the sheer number of ties obscures the structure of the network. -Autograph includes three arguments that can help with this. +where the sheer number of ties obscures any structure that might be in the network. +Autograph includes three arguments that can help with this, +some of which are turned on by default, +so we will need to turn them off to see the effect of each in isolation. + +#### Isolates {#isolates} + +First, it is possible that the hairball is not a hairball at all, +but a network with many disconnected components or, especially, `r gloss("isolates","isolate")`, +that each need to be drawn. +As each `r gloss("component")` is drawn under a force-directed layout, +the components will repel each other and the `r gloss("giant")` component will be squeezed into a clump. + +The `isolates` argument in `graphr()` provides some options for what to do with them instead: +`"legend"` (the default) drops them from the drawing but records how many there were in the legend, +`"caption"` notes them in a caption instead, +and `"keep"` leaves them in place. +**Add ten unconnected characters to `fict_lotr` and compare keeping them with noting them in the legend.** + +```{r isolates, fig.width=9} +lotr_iso <- to_unlabelled(fict_lotr) |> + add_nodes(10) +(graphr(lotr_iso, isolates = "keep") + ggtitle("keep") | + graphr(lotr_iso, isolates = "legend") + ggtitle("legend")) +``` #### Bundling ties {#bundling} -The first option is to draw all of the ties 'bundled' together, +The second option is to draw all of the ties 'bundled' together, which can reveal where the most common paths through the network are. `edge_bundle` pulls ties that travel in similar directions into shared paths — like cabling them together — so that the main 'highways' of the network stand out. It is off by default; set `edge_bundle = TRUE` (or name a specific algorithm: `"force"`, `"path"`, or `"minimal"`) to switch it on. -**`ison_lawfirm` records 71 lawyers and 2571 -ties between them, which is about as thick a hairball as a network this small -can be. Compare it drawn with and without bundling (turn backbone off too for clearest comparison results).** +**`ison_lawfirm` records 71 lawyers and 2571 ties between them, +which is about as thick a hairball as a network this small can be. +Compare it drawn with and without bundling (turn backbone off too for clearest comparison results).** ```{r bundle, fig.width=9} graphr(ison_lawfirm, backbone = FALSE) + ggtitle("Unbundled") | @@ -596,23 +619,6 @@ before reaching for both. A bundled tie cannot carry a fading of its own — bundling merges ties into shared paths — so where both are asked for, the backbone still shapes the layout but every tie is drawn alike. -#### Isolates {#isolates} - -At the other extreme, many networks contain `r gloss("isolates","isolate")` — -unconnected nodes — which, under a force-directed layout, drift to the margins -and squeeze the connected core into a clump. The `isolates` argument decides -what happens to them: `"legend"` (the default) drops them from the drawing but -records how many there were in the legend, `"caption"` notes them in a caption -instead, and `"keep"` leaves them in place. **Add two unconnected characters -to `fict_lotr` and compare keeping them with noting them in the legend.** - -```{r isolates, fig.width=9} -lotr_iso <- fict_lotr |> - add_nodes(2, list(name = c("Tom Bombadil", "Goldberry"))) -(graphr(lotr_iso, isolates = "keep") + ggtitle("keep") | - graphr(lotr_iso, isolates = "legend") + ggtitle("legend")) -``` - For very large real-world networks such as `irps_blogs`, these work well together: a backbone picks out the ties that hold the connected core together (or `edge_bundle = TRUE`, if you would rather see the paths the ties take than @@ -1121,18 +1127,55 @@ is the point of this section. Quality measures a layout algorithm might attend to include: - minimising the _crossing number_ of edges/ties in the graph -([planar graphs](https://www.jasondavies.com/planarity/) require no crossings) +([planar graphs](https://www.jasondavies.com/planarity/) require no crossings): +`check_crossings()` - minimising the _slope number_ of distinct edge slopes in the graph -(where vertices are represented as points on a Euclidean plane) +(where vertices are represented as points on a Euclidean plane): +`check_slopes()` - minimising the _bend number_ in all edges in the graph -(every graph has a right angle crossing (RAC) drawing with three bends per edge) -- minimising the _total edge length_ -- minimising the _maximum edge length_ -- minimising the _edge length variance_ -- maximising the _angular resolution_ or sharpest angle of edges meeting at a common vertex -- minimising the _bounding box_ of the plot -- evening the _aspect ratio_ of the plot -- displaying _symmetry groups_ (subgraph automorphisms) +(every graph has a right angle crossing (RAC) drawing with three bends per edge): +not measured, since the bends are set by `graphr()`'s `edge_curved` and +`edge_bundle` arguments rather than by the layout +- minimising the _total edge length_: `attr(check_lengths(p), "total")` +- minimising the _maximum edge length_: `attr(check_lengths(p), "max")` +- minimising the _edge length variance_: `attr(check_lengths(p), "variance")` +- maximising the _angular resolution_ or sharpest angle of edges meeting at a common vertex: +`attr(check_angles(p), "min")` +- minimising the _bounding box_ of the plot: not measured +- evening the _aspect ratio_ of the plot: not measured +- displaying _symmetry groups_ (subgraph automorphisms): not measured + +Each of these functions scores a drawing rather than the network it draws, +so two layouts of the same network can be compared. +`check_drawing()` runs them all at once, beside `check_stress()`, +which reports how far the distances drawn depart from the distances +through the network. +**Compare a stress layout with a circle.** + +```{r check-drawing} +sw_stress <- graphr(ison_southern_women, layout = "stress") +sw_circle <- graphr(ison_southern_women, layout = "circle") +rbind(stress = check_drawing(sw_stress), circle = check_drawing(sw_circle)) +``` + +Every column is read downwards except `angle_min`, which is read upwards. +Fewer crossings, fewer slopes, shorter ties, more even ties, and a wider +smallest angle all make a drawing easier to read. +`nodes`, `ties` and `angle_ideal` are context rather than scores. +`angle_ideal` is the widest smallest angle the degrees of this network allow, +so `angle_min` is read as a share of it. +The floors are worth knowing as well: +a network of this many ties cannot be drawn without crossings at all, +and no drawing of it can use fewer slopes than half its largest degree. + +No layout wins on every measure. +The circle draws every node on one ring, which costs it crossings and length: +it draws many times the crossings of the stress layout, and three times the +total tie length. It buys the other measures with the same ring: +fewer slopes, ties of a more even length, and more room between the ties that +meet at a node. Its stress is the worse of the two, +because a layout that never set out to draw the path distances +scores poorly on them by design. Graph layouts available in the `{igraph}`, `{ggraph}`, `{graphlayouts}`, and `{autograph}` packages can be used in `graphr()`.