From 10eeb723affa41771b13cecbb367990ac728f546 Mon Sep 17 00:00:00 2001 From: zorrooz <3153960281@qq.com> Date: Sat, 19 Sep 2026 18:05:11 +0800 Subject: [PATCH] fix+docs: P0/P1 behaviour fixes and 1.0.0 doc alignment Behaviour: - mark_bar no longer replaces a pre-installed continuous y scale - nested compose bakes inner composite annotations before assembly - compose_annot(gap=) keeps spacer rows out of the base cell - label_* hide sentinel is isFALSE(); text='FALSE' stays text - errorbar/ribbon width injected only for geoms that accept it - colour/fill mirroring attaches the twin scale with guide='none' - make_mark passes mark_name into ._register_mark_method - mark_map layer colour/fill uses the curated default palette - mark_rule segment path shares the hline linewidth default - style() accepts theme functions; warns when font args clash with a theme object - compose default sizes honour present sides, annot gaps, and design grids - composite inset legend parking uses patchwork '&' when needed Docs / hygiene: - NEWS.md 1.0.0 entry; README lifecycle maturing; AGENTS/API/pkgdown aligned - export dpi documented as 600; plotit defaults 89x56 mm in api.Rmd - lintr clean; tests use plotit::: for internals; root artifacts removed --- AGENTS.md | 89 ++++++++++--------- NEWS.md | 80 ++++++++++++++++- R/compose.R | 115 +++++++++++++++++++++---- R/factory.R | 2 +- R/label.R | 4 +- R/mark.R | 55 +++++++++--- R/mark_relational.R | 2 +- R/mark_style.R | 16 ++-- R/plot.R | 8 +- R/style.R | 38 +++++++- R/theme.R | 22 +++-- README.md | 11 ++- README_ZH.md | 15 ++-- _pkgdown.yml | 2 +- man/mark_errorbar.Rd | 4 +- tests/testthat/test-compose.R | 50 +++++++++++ tests/testthat/test-export.R | 2 +- tests/testthat/test-graph.R | 4 +- tests/testthat/test-label.R | 9 ++ tests/testthat/test-mark-extended.R | 16 +++- tests/testthat/test-mark-stat-entity.R | 6 +- tests/testthat/test-mark.R | 21 +++++ tests/testthat/test-plot.R | 54 +++++++++++- tests/testthat/test-stage8-visual.R | 15 +++- tests/testthat/test-style-contract.R | 4 +- tests/testthat/test-style.R | 16 ++++ vignettes/api.Rmd | 2 +- 27 files changed, 539 insertions(+), 123 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 527b5f4..e4d0d58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -200,7 +200,7 @@ G2 的每个复合 Mark 内部展开为 2-5 个基础 Mark 的组合,这与 pl - **统计 Mark**:对标 Vega-Lite 复合 Mark (`boxplot`/`errorbar`/`errorband`)的统计聚合能力 + G2 corelib 的 `density`/`heatmap`/`beeswarm` - **复合 Mark**:对标 Vega-Lite `layer` 运算符和 G2 graphlib/plotlib 的组合模式,封装 2+ 已有 Mark 的固定搭配 -**完整规划**(40 种,对标 Vega-Lite 15+ 种 + AntV G2 30+ 种,三层体系:基础 → 统计 → 复合;已全部实现。历史 27 种规划表保留如下,28–39 为 2025-12 覆盖扩展,40 为矩阵热图轮新增 `mark_heatmap`): +**完整规划**(43 种已实现:19 基础 + 12 统计 + 12 复合含关系。历史规划表 27+13 行如下;`mark_ribbon`/`mark_image`/`mark_encircle` 为阶段 3 落地的复合/关系类补充,见 §3.2 与 NAMESPACE): | # | 层级 | 函数 | 类别 | 底层 R 实现 | 对标来源 | 用途 | |---|---|---|---|---|---|---| @@ -690,18 +690,19 @@ data |> as_graph() |> plotit() |> #### 3.3.9 `export()` — 导出 -`export(plot, filename, width=NULL, height=NULL, dpi=300, device=NULL, ...)` +`export(plot, filename, width=NULL, height=NULL, dpi=600, device=NULL, ...)` 尺寸优先级链:显式传参 > meta 存储值 > autofit 自适应。 - `autofit=FALSE` + 未传尺寸:通过 gtable 测量获得总尺寸(面板尺寸来自 meta,通过 `._build_fixed_gtable()` 固定;轴/标签/图例由当前主题决定) -- `autofit=TRUE` + 未传尺寸:回退 `getOption("plotit.default_width", 5)` / `getOption("plotit.default_height", 3.5)`(英寸) +- `autofit=TRUE` + 未传尺寸:回退 `getOption("plotit.default_width", 5)` / `getOption("plotit.default_height", 3.5)`(英寸);实现亦可经 `._default_panel_size()` 使用 Nature 面板 89×56 mm 折算 - 显式传入的 `width`/`height` 遵循 `plotit()` 时设定的 `size_unit` 换算。单位统一为英寸后传给 `ggsave()` - `device` 从文件名扩展名推断(`.pdf` / `.png` / `.svg` 等) +- `export(list_of_plotit, "out.pdf")`:多页 PDF(矢量设备;`dpi` 不生效) #### 3.3.10 图片尺寸算法 -`plotit()` 的 `width`/`height` 指面板尺寸(非总尺寸)。`autofit=FALSE` 时通过 `patchwork::plot_layout()` 固定面板为绝对单位。 +`plotit()` 的 `width`/`height` 指面板尺寸(非总尺寸)。`autofit=FALSE` 时构造期把 meta 尺寸烘焙为 ggplot2 4.0+ 的 `theme(panel.widths=, panel.heights=)`(WYSIWYG)。 **契约边界**:面板尺寸遵守 ±1% 浮点误差。总尺寸(面板+轴+标签+图例+边距)为衍生值,不在 API 契约内,可能随主题/字体/设备版本变化。 @@ -738,11 +739,12 @@ data |> as_graph() |> plotit() |> 全部返回 `plotit_composite`(`@gg` + `@plots` + `@layout` + `@annotations`)。 -**`compose_grid(..., ncol=NULL, nrow=NULL, byrow=TRUE, widths=NULL, heights=NULL, guides="collect", axes="keep", tag_levels=NULL)`** +**`compose_grid(..., ncol=NULL, nrow=NULL, byrow=TRUE, widths=NULL, heights=NULL, guides="collect", axes="keep", axis_titles=NULL, design=NULL, tag_levels=NULL)`** - 默认 `ncol=NULL, nrow=NULL` → `ncol=1`(纵向堆叠)。仅设 `nrow=1` 则横向并排 - `guides="collect"` 默认合并相同图例(避免重复图例并排),可传 `"keep"` 独立 -- `axes` 封装 `patchwork::plot_layout(axes=)` -- 嵌套:接受 `plotit_composite`,组合可嵌套 +- `axes` / `axis_titles` 封装 `patchwork::plot_layout(axes=, axis_titles=)` +- `design`:布局字符串或 area 向量列表;给定时覆盖 ncol/nrow/byrow(警告) +- 嵌套:接受 `plotit_composite`,组合可嵌套;内层 annotations 在组装前烘焙进 gg **组合图主题语义**:composite 上的 `style()` 经 patchwork `&` 作用到**全部**子面板(`+` 只作用于末图);`plot_annotation()` 惰性渲染时附带 `._theme_default()`,标题/副标题/脚注层级与单图一致。print/export 未显式给尺寸时用 `._composite_default_size()`(子图 meta 面板 + chrome 余量)——禁止直接测量 patchworkGrob(null 单位视口外不解析)。 @@ -758,6 +760,11 @@ data |> as_graph() |> plotit() |> `label_title`/`label_subtitle`/`label_caption` → 写入 `@annotations`,`print()`/`export()` 时通过 `plot_annotation()` 惰性渲染(消除调用顺序依赖)。不支持的操作:`mark_*`/`scale_*`/`project_*`/`split_*`/`label_axis`/`label_legend` 不接受 `plotit_composite`——先构建再组合。 +**`compose_annot(base, top=NULL, bottom=NULL, left=NULL, right=NULL, heights=NULL, widths=NULL, gap=0, guides="collect", align="panel", on_top=FALSE)`** +- 基图 + 最多四侧附着条带(树状图/注释条/边际密度);与 `compose_marginal` 共享 `._assemble_annot()` 引擎 +- `gap` 以 spacer 行/列实现,不并入 base 单元格 +- 默认画布尺寸按 sides + strip 面板 + gap 计算 + ##### `compose_grid` 细节 - 嵌套:接受 `plotit_composite`,组合可嵌套。单图:`compose_grid(p)` 合法。 - `tag_levels` 存入 `@annotations`,惰性注入:`"A"`/`"a"`/`"1"`/`"i"` 或自定义字符向量。 @@ -773,7 +780,7 @@ data |> as_graph() |> plotit() |> ### 4.1 文件结构 ``` -R/:class.R encode.R utils.R plot.R mark.R mark_relational.R scale.R project.R split.R label.R style.R output.R compose.R factory.R zzz.R +R/:class.R encode.R utils.R theme.R style.R output.R label.R compose.R mark_style.R graph.R mark.R factory.R layout.R mark_image.R mark_relational.R plot.R project.R scale.R split.R zzz.R tests/testthat/:test-.R 按函数族分文件 ``` @@ -917,8 +924,8 @@ export(p, "output.pdf", dpi = 300) | 阶段 | 名称 | 范围 | 状态 | |---|---|---|---| | 0 | 固本 | 架构清债 + 代码质量 | 🔄 进行中(单图侧 patchwork 剥离、`._sync_labels` 抽象、mark 工厂函数、@examples 均已 ✅;剩余:组合图 patchwork 剥离) | -| 1-4 | mark 扩展 | 13 种新 mark(20 种规划 − 6 已实现 − 1 已移除组合) | ✅ 已完成(现 40 种,见 §3.2;2025-12 覆盖轮 +12,矩阵热图轮 +1) | -| 5 | 收尾 | 文档补齐、全量验证、发布准备 | ⬜ 未开始 | +| 1-4 | mark 扩展 | 目录覆盖 | ✅ 已完成(现 **43** 种,见 §3.2 与 NAMESPACE;含 ribbon/image/encircle/heatmap) | +| 5 | 收尾 | 文档补齐、全量验证、发布准备 | 🔄 进行中(Version 已 1.0.0;NEWS/README/AGENTS 已对齐本轮;R CMD check 多平台与 styler 待跑) | --- @@ -1050,11 +1057,13 @@ export(p, "output.pdf", dpi = 300) **验收标准**: -- [x] 20 种 mark 至少 15 个已实现(≥75% mark 覆盖率)——实际 40 种已达成 +- [x] 20 种 mark 至少 15 个已实现(≥75% mark 覆盖率)——实际 **43** 种已达成 - [ ] `R CMD check` 4 平台(Linux/macOS/Windows + R-devel)零 ERROR 零 WARNING -- [ ] `lintr::lint_package()` 零 lint 问题 +- [x] `lintr::lint_package()` 本地零 lint 问题(本轮已修缩进/pipe continuation) - [ ] pkgdown 网站完整渲染所有函数参考页 -- [ ] 五篇站点文章(§4.9)与当前 API 一致,无第 6 篇 vignette +- [x] 五篇站点文章(§4.9)与当前 API 一致,无第 6 篇 vignette +- [x] 版本号 1.0.0(DESCRIPTION) +- [x] NEWS.md 汇总(1.0.0 条目) --- @@ -1062,11 +1071,12 @@ export(p, "output.pdf", dpi = 300) | 层级 | 函数族 | 1.0 目标 | 已实现 | 完成度 | |------|--------|----------|--------|--------| -| 内层 | plotit + encode | 2 | 2 | 100% | -| 内层 | mark_* | 20(目标 ≥15) | 40 | 200%(超目标) | -| 内层 | scale_* + project_* + split_* + label_* + style+export | 22 | 22 | 100% | -| 最外层 | compose_* | 3 | 3 | 100% | -| **总计** | | **~49** | **67** | **137%** | +| 内层 | plotit + encode + add_ggplot + make_* | 2 | 6 | — | +| 内层 | mark_* | 20(目标 ≥15) | **43** | 超目标 | +| 内层 | scale_* + project_* + split_* + label_* + style + export | 22 | 23(含 defunct `scale_radius`) | 100% | +| 关系 | as_graph + layout_* | — | 8 | — | +| 最外层 | compose_* | 3 | **4**(含 `compose_annot`) | 100% | +| **总计(NAMESPACE exports)** | | | **82** | — | ### 9.5 1.0 检查清单 @@ -1094,11 +1104,11 @@ export(p, "output.pdf", dpi = 300) **阶段 5(收尾)**: - [ ] `R CMD check` 4 平台零 ERROR 零 WARNING -- [ ] lintr 零问题 +- [x] lintr 零问题(本地已清) - [ ] pkgdown 完整渲染 -- [ ] Vignette / README 更新 -- [ ] 版本号 1.0.0 -- [ ] NEWS.md 汇总 +- [x] Vignette / README 更新(五篇 IA + lifecycle maturing + compose_annot) +- [x] 版本号 1.0.0 +- [x] NEWS.md 汇总 --- @@ -1264,35 +1274,36 @@ parse(file = "test.R") --- -## 14. 发布级重构 API 设计基线(目标态,未实施) +## 14. 发布级重构 API 设计基线(多数已落地;剩余见状态列) > 来源:阶段 0 调研(`.agent/research/`)→ 系统性回顾 + API 设计套件(`.agent/design/`,v1)。 -> 用户已裁决 5 项决策(design/10 §4)。**本节仅登记目标态与指针,实施前 §3 现行约定不变**; -> 工程排期见 `.agent/plan.md`,实施完成后相应条目并入 §3 并从本节移除。 +> 用户已裁决 5 项决策(design/10 §4)。**已实施条目以 §3 与 NAMESPACE 为准**; +> 工程排期见 `.agent/plan.md`。 -### 14.1 新增导出(原 4 个;mark_ribbon/mark_image/mark_encircle 已于阶段 3 落地移除,余 `compose_annot` 1 个,均过三闸门,justification 见 design/03、06) +### 14.1 新增导出 -| 函数 | 层 | 一句话 | 详细设计 | -|---|---|---|---| -| `compose_annot` | 组合 | 任意侧附着条带(复杂热图旗舰配方使能器;D4) | design/06 §4 | +| 函数 | 状态 | 一句话 | +|---|---|---| +| `compose_annot` | ✅ 已实现并导出 | 任意侧附着条带(复杂热图旗舰配方使能器;D4) | +| `mark_ribbon` / `mark_image` / `mark_encircle` | ✅ 已实现并导出 | 统计带 / 图像散点 / 分组圈注 | -### 14.2 主要扩参(全部进扩展契约层) +### 14.2 主要扩参(下列均已进入实现,详见 man/*.Rd) -- `mark_errorbar`/`mark_ribbon`:`stat`(identity/mean_sem/mean_sd/mean_range/mean_ci95)、`level`、`ci_method`(03§2) -- `mark_heatmap`:`show_numbers`/`number_format`/`number_color`/`na_color`;`cluster` 扩型接受 hclust/list/字符向量四态(03§5.1、06§5) -- `scale_color/fill`:`na_color`/`n_bins`/`mid`;palette 白名单扩至 20 方案(新增发散类 6 个,默认 `rdbu`;D-4 裁决本期封顶)(04§1–3) -- `project_polar`:`start`/`end`/`reverse`,`direction` 进弃用周期(05§2) -- `split_wrap`/`split_grid`:`dir` 八向码 / `axes` 透传(05§5) -- `compose_grid`:`design`(字符串/数值向量列表)/`axis_titles`/三态尺寸(06§2) -- `layout_tree`:`leaf_spacing`/`edge="elbow"`(07§2) -- `export`:接受 plotit 列表 → 多页 PDF(08§3) +- `mark_errorbar`/`mark_ribbon`:`stat`、`level`、`ci_method` ✅ +- `mark_heatmap`:`show_numbers`/`number_format`/`number_color`/`na_color`;`cluster` ✅ +- `scale_color`/`scale_fill`:`na_color`/`n_bins`/`mid`;发散色板 ✅ +- `project_polar`:`start`/`end`/`reverse`/`rotate_angle`;`direction` 弃用 ✅ +- `split_wrap`/`split_grid`:`dir` ✅ +- `compose_grid`:`design`/`axis_titles` ✅ +- `layout_tree`:`leaf_spacing`/`edge="elbow"` ✅ +- `export`:plotit 列表 → 多页 PDF ✅ ### 14.3 已裁决的定位边界(并入非目标) - 仪表类 `mark_gauge`/`mark_liquid` **定位外**;`mark_venn` 维持 P3 二轮评审(D-5)。 - 不自动计算统计检验 p 值:`mark_significance` 维持纯外观,预计算表经 `comparisons=` 喂入(D-1)。 - mark_raster / layout_voronoi·delaunay:纯 R 自研、P2/P3 慢车道(D-2)。 -- NEWS.md 于阶段 7 随 1.0.0 统一补写(D-3);palette 名录本期 20 方案封顶(D-4)。 +- NEWS.md 已随 1.0.0 写入(§9.3 5.6);palette 名录本期 20 方案封顶(D-4)。 ### 14.4 待核实项(实施前补验,design/01 §4 全表) diff --git a/NEWS.md b/NEWS.md index 2655169..512c495 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1 +1,79 @@ -# plotit (development version) +# plotit 1.0.0 + +First stable API release of the declarative plotit pipeline on top of ggplot2 +(>= 4.0.0). The 1.0 contract covers verb-prefix construction (`plotit` / +`encode`), 43 `mark_*` layers, scale/project/split/label/style/export, graph +layouts, and multi-panel `compose_*`. + +## Plot construction + +* `plotit()` / `encode()`: WYSIWYG Nature single-column panel default + (89 x 56 mm), tidyplots-calibrated theme tokens, friendly/viridis default + palettes via a single colour-scale decision point. +* Discrete colour/fill mirroring attaches the twin channel with + `guide = "none"` so one group variable never renders two legends. +* `add_ggplot()` escape hatch for raw ggplot2 components without leaving the + pipe. + +## Marks (43) + +* **Basic geometry**: point, line, area, bar, rect, polygon, text, label, + rule, path, step, rug, spoke, curve, histogram, density, boxplot, violin, + map. +* **Statistical**: smooth, hex, bin2d, density_2d, contour, count, corr, + heatmap, ecdf, qq, qq_line. +* **Composite / relational**: significance, errorbar, ribbon, lollipop, + dumbbell, forest, beeswarm, image, encircle, sankey, treemap, network, + chord. +* `mark_errorbar` / `mark_ribbon`: statistical entities + (`mean_sem` / `mean_sd` / `mean_range` / `mean_ci95`); cap width is + method-injected only for `caps = TRUE` (token 0.4). +* `mark_bar` no longer replaces a pre-installed continuous y scale when + applying the tidyplots lower-expand flush. +* `mark_rule` segment path shares the reference-line linewidth default. +* `mark_map` layer-level colour/fill mappings use the curated default + palette decision point. +* `make_mark()` registers methods under the requested mark name so style + defaults and chrome lookups resolve correctly. + +## Scales, projects, splits, labels + +* `scale_*`: Vega-style trans/range matrix; colour schemes include sequential + viridis family and diverging anchors (`mid=`); `scale_radius()` is defunct + in favour of `scale_size()`. +* `project_polar()` supports `start`/`end`/`reverse`/`rotate_angle`; + `direction` is deprecated in favour of `reverse`. +* `project_parallel()`, `project_cartesian()`, `project_map()`. +* `label_*` three-parameter protocol (`reset` > `hide` > `text`); hide is + logical FALSE only (literal `"FALSE"` is text). +* `style()` accepts a theme object or a theme function; font args forwarded + to theme functions and warned when a complete theme object is also given. + +## Graph data and layouts + +* `as_graph()`, `layout_force()`, `layout_circle()`, `layout_tree()`, + `layout_dendrogram()`, `layout_sankey()`, `layout_chord()`, + `layout_treemap()` — pure-R engines (no igraph/ggraph/circlize/ggsankey). +* Relational sugar marks: `mark_sankey`, `mark_treemap`, `mark_network`, + `mark_chord` over edges-table API; formula `data = ~nodes` / `~edges` + against `@graph`. + +## Composition + +* `compose_grid()` (design layouts, axis-title sharing), + `compose_inset()`, `compose_marginal()`, `compose_annot()`. +* Nested composites apply inner annotations before outer assembly. +* `compose_annot(gap=)` no longer absorbs spacer rows into the base cell. +* Default composite canvas sizes account for present marginal sides, annot + strips + gaps, and design grid geometry. +* Inset legend parking uses patchwork `&` for composite insets. + +## Export + +* `export()` for single plots and composites; `dpi` default **600**; + multipage PDF when given a list of plotit objects. + +## Documentation + +* Five-article pkgdown IA (Get Started, Gallery, Advanced, API, Design Goals). +* NEWS.md summarises the 1.0 surface; README lifecycle set to maturing. diff --git a/R/compose.R b/R/compose.R index caf8aca..93a4827 100644 --- a/R/compose.R +++ b/R/compose.R @@ -40,8 +40,8 @@ NULL } # Sync lazy labels on a sub-plot before extraction so label_* settings -# survive composition (AGENTS.md 1.2). Composites are skipped: their -# labels live in annotations, not meta@labels. +# survive composition (AGENTS.md 1.2). Plain plotit objects sync meta@labels; +# nested composites carry annotations instead (applied in ._prep_subplot_gg). #' Sync lazy labels on a sub-plot before composition. #' @noRd #' @keywords internal @@ -53,13 +53,21 @@ NULL } } -# Full subplot preparation: sync lazy labels, extract the raw ggplot, and -# strip baked panel sizing. One choke point for every compose_* entry. +# Full subplot preparation: sync lazy labels (or apply nested-composite +# annotations), extract the raw ggplot, and strip baked panel sizing. +# One choke point for every compose_* entry. #' Prepare a sub-plot's raw ggplot for composite assembly. #' @noRd #' @keywords internal ._prep_subplot_gg <- function(p) { - ._reset_sizing(._extract_gg(._sync_subplot(p))) + if (S7::S7_inherits(p, plotit_composite)) { + # Nested composites must bake their title/caption/tags into the gg + # before extraction; otherwise outer compose_* silently drops them. + gg <- ._apply_annotations(p) + } else { + gg <- ._extract_gg(._sync_subplot(p)) + } + ._reset_sizing(gg) } # Fresh annotation skeleton shared by all compose_* constructors. @@ -169,18 +177,49 @@ NULL n <- length(sizes) lt <- cmp@layout$type %||% "grid" if (lt == "marginal") { - widths <- cmp@layout$widths %||% c(4, 1) - heights <- cmp@layout$heights %||% c(1, 4) + sides <- cmp@layout$sides %||% c("top", "right") main <- sizes[[1]] + w_fac <- 1 + h_fac <- 1 + if ("right" %in% sides) { + widths <- cmp@layout$widths %||% c(4, 1) + w_fac <- sum(widths) / widths[1] + } + if ("top" %in% sides) { + heights <- cmp@layout$heights %||% c(1, 4) + h_fac <- sum(heights) / heights[2] + } list( - width = main$w * sum(widths) / widths[1] + allowance_w, - height = main$h * sum(heights) / heights[2] + allowance_h + width = main$w * w_fac + allowance_w, + height = main$h * h_fac + allowance_h ) } else if (lt == "inset") { base <- sizes[[1]] list(width = base$w + allowance_w, height = base$h + allowance_h) + } else if (lt == "annot") { + sides <- cmp@layout$sides %||% character() + gap <- as.numeric(cmp@layout$gap %||% 0) + base <- sizes[[1]] + w_extra <- 0 + h_extra <- 0 + for (i in seq_along(sides)) { + sz <- sizes[[i + 1L]] %||% base + if (sides[[i]] %in% c("left", "right")) { + w_extra <- w_extra + sz$w + gap + } else { + h_extra <- h_extra + sz$h + gap + } + } + list( + width = base$w + w_extra + allowance_w, + height = base$h + h_extra + allowance_h + ) } else { - grid <- ._grid_dims(cmp@layout, n) + grid <- if (!is.null(cmp@layout$design)) { + ._design_dims(cmp@layout$design, n) + } else { + ._grid_dims(cmp@layout, n) + } units <- ._grid_units(sizes, grid$ncol, grid$nrow, cmp@layout$byrow %||% TRUE) list( width = sum(units$widths) + allowance_w, @@ -189,6 +228,30 @@ NULL } } +# Effective (ncol, nrow) of a compose_grid design spec. +# Invalid designs fall back to a 1-column stack so the later +# ._parse_design() can raise the targeted abort message. +#' @noRd +#' @keywords internal +._design_dims <- function(design, n) { + if (is.character(design) && length(design) == 1L) { + rows <- strsplit(design, "\n", fixed = TRUE)[[1]] + nrow <- max(1L, length(rows)) + ncol <- max(1L, max(nchar(rows))) + return(list(ncol = ncol, nrow = nrow)) + } + ok_list <- is.list(design) && length(design) > 0 && + all(vapply(design, function(a) { + is.numeric(a) && length(a) == 4 && all(is.finite(a)) + }, logical(1))) + if (ok_list) { + bottoms <- vapply(design, function(a) as.integer(a[[3]]), integer(1)) + rights <- vapply(design, function(a) as.integer(a[[4]]), integer(1)) + return(list(ncol = max(1L, max(rights)), nrow = max(1L, max(bottoms)))) + } + ._grid_dims(list(ncol = NULL, nrow = NULL), n) +} + # Assemble a list of plots into a patchwork via wrap_plots() #' Assemble a list of plots into a patchwork via wrap_plots(). #' @noRd @@ -238,7 +301,11 @@ NULL lt <- layout$type %||% "grid" if (lt == "grid" || is.null(layout$type)) { sizes <- lapply(plots, ._subplot_panel_size) - grid <- ._grid_dims(layout, length(sizes)) + grid <- if (!is.null(layout$design)) { + ._design_dims(layout$design, length(sizes)) + } else { + ._grid_dims(layout, length(sizes)) + } units <- ._grid_units(sizes, grid$ncol, grid$nrow, layout$byrow %||% TRUE) if (is.null(widths)) widths <- grid::unit(units$widths, "in") if (is.null(heights)) heights <- grid::unit(units$heights, "in") @@ -457,12 +524,18 @@ compose_inset <- function( # The inset is self-contained: its legend must not float outside the inset # box onto the base canvas (T2.3). Park it inside the inset panel; a user # style() on the inset plot overrides this in the usual way. - inset_gg <- inset_gg + ggplot2::theme( + # patchwork composites need `&` so every sub-panel keeps the legend inside. + inset_legend_theme <- ggplot2::theme( legend.position = "inside", legend.position.inside = c(0.98, 0.98), legend.justification = c(1, 1), legend.background = ggplot2::element_rect(fill = "white", colour = NA) ) + if (inherits(inset_gg, "patchwork")) { + inset_gg <- inset_gg & inset_legend_theme + } else { + inset_gg <- inset_gg + inset_legend_theme + } gg <- base_gg + patchwork::inset_element( inset_gg, left = left, @@ -603,6 +676,7 @@ compose_marginal <- function( plots = c(list(main), strips), layout = list( type = "marginal", + sides = names(strips), widths = widths, heights = heights, align = align @@ -627,11 +701,9 @@ compose_marginal <- function( sides <- intersect(c("top", "bottom", "left", "right"), names(strips)) nrow_m <- 1L + has("top") + has("bottom") ncol_m <- 1L + has("left") + has("right") - base_row <- 1L + has("top") - base_col <- 1L + has("left") - # Design matrix with optional gap spacer rows/cols. Numbering: base = 1, - # strips follow the fixed top/bottom/left/right order. + # Design matrix with optional gap spacer rows/cols. Base occupies its own + # slot cell; strips follow the fixed top/bottom/left/right order. row_slots <- character() if (has("top")) row_slots <- c(row_slots, "top") if (has("top") && gap > 0) row_slots <- c(row_slots, "gapv") @@ -645,12 +717,17 @@ compose_marginal <- function( if (has("right") && gap > 0) col_slots <- c(col_slots, "gaph") if (has("right")) col_slots <- c(col_slots, "right") - # Design as patchwork area() objects. The base spans its whole grid + # Resolve base cell from the slot vector so gap spacers are not absorbed + # into the base area (base must not span gapv/gaph). + base_row <- match("base", row_slots) + base_col <- match("base", col_slots) + + # Design as patchwork area() objects. The base occupies its whole grid # row AND column so strips align to the base panel by construction; # each strip occupies its single slot cell. base_area <- patchwork::area( base_row, base_col, - match("base", row_slots), match("base", col_slots) + base_row, base_col ) areas <- list(base_area) for (s in sides) { @@ -968,7 +1045,7 @@ S7::method(style, plotit_composite) <- function( base_family = NULL, base_theme = NULL ) { - thm <- base_theme %||% ._theme_default(base_size, base_family) + thm <- ._resolve_style_theme(base_size, base_family, base_theme) # patchwork: `+` adds to the last sub-plot only; `&` applies to every # panel, matching the single-plot style() semantics. if (inherits(plot@gg, "patchwork")) { diff --git a/R/factory.R b/R/factory.R index 80487f2..e28e55d 100644 --- a/R/factory.R +++ b/R/factory.R @@ -57,7 +57,7 @@ make_mark <- function(name, geom_fun) { } generic <- ._make_mark_generic(name) - ._register_mark_method(generic, geom_fun) + ._register_mark_method(generic, geom_fun, mark_name = name) # Make the new mark callable from the calling environment (same pattern # as make_theme), so it works inside pipelines right away. assign(name, generic, envir = parent.frame()) diff --git a/R/label.R b/R/label.R index bf7acd1..cf869af 100644 --- a/R/label.R +++ b/R/label.R @@ -123,7 +123,9 @@ NULL #' @keywords internal ._sync_one_label <- function(plot, slot_name, theme_el_name, labs_name) { val <- S7::prop(plot@meta@labels, slot_name) - if (isTRUE(val == FALSE)) { + # Hide sentinel is logical FALSE only. isFALSE() avoids the R gotcha + # `"FALSE" == FALSE` being TRUE, which would blank a literal "FALSE" title. + if (isFALSE(val)) { plot@gg <- plot@gg + ._theme_el(theme_el_name, ggplot2::element_blank()) } else if (is.null(val)) { plot@gg <- plot@gg + ._theme_el(theme_el_name, NULL) diff --git a/R/mark.R b/R/mark.R index 25fffd4..13cb32f 100644 --- a/R/mark.R +++ b/R/mark.R @@ -243,10 +243,13 @@ NULL #' Register an S7 method for a standard mark. #' @noRd #' @keywords internal -._register_mark_method <- function(generic, geom_fun) { +._register_mark_method <- function(generic, geom_fun, mark_name = NULL) { force(generic) force(geom_fun) - mark_name <- deparse(substitute(generic)) + # Built-in one-line registrations pass the generic as a symbol + # (deparse(substitute()) yields "mark_point"); factory callers pass + # mark_name explicitly because the local binding is always "generic". + mark_name <- mark_name %||% deparse(substitute(generic)) # Restrict graph auto-binding to what this mark family understands. bind_aes <- ._MARK_BIND_AES[[mark_name]] @@ -630,6 +633,20 @@ S7::method(mark_map, plotit_class) <- function( if (!is.null(mapping) && (!is.null(mapping$colour) || !is.null(mapping$fill))) { plot <- ._clear_default_color(plot, mapping) } + # Layer-level curated palette -- same decision point as ._mark_impl. + if (!is.null(mapping)) { + unmanaged <- setdiff( + intersect(c("colour", "fill"), names(mapping)), + ._colour_managed_get(plot) + ) + for (aes_name in unmanaged) { + sc <- ._default_colour_scale(aes_name, layer_data, mapping[[aes_name]]) + if (!is.null(sc)) { + plot@gg <- plot@gg + sc + plot <- ._colour_managed_add(plot, aes_name) + } + } + } geom <- ggplot2::geom_sf(mapping = mapping, data = data, ...) plot <- ._add_geom(plot, geom, rasterize = rasterize, rasterize_dpi = rasterize_dpi, @@ -882,6 +899,9 @@ S7::method(mark_rule, plotit_class) <- function( if (is.null(ann_args$colour) && !"colour" %in% ._user_owned_aes(plot, mapping)) { ann_args$colour <- ._MARK_STYLE$soft } + if (is.null(ann_args$linewidth) && !"linewidth" %in% ._user_owned_aes(plot, mapping)) { + ann_args$linewidth <- ._MARK_STYLE$lw_thin + } geom_call <- do.call( ggplot2::annotate, c(list("segment"), ann_args, rlang::list2(...)) ) @@ -1393,7 +1413,7 @@ mark_heatmap <- S7::new_generic( function(plot, cluster = c("both", "row", "column", "none"), scale = c("none", "row", "column"), show_numbers = FALSE, number_format = "%.2f", number_color = NULL, - na_color = ._MARK_STYLE$na_colour, range = NULL, ..., + na_color = ._MARK_STYLE$na_colour, range = NULL, ..., rasterize = FALSE, rasterize_dpi = 300, rasterize_dev = "cairo") { S7::S7_dispatch() } @@ -1618,7 +1638,9 @@ S7::method(mark_heatmap, plotit_class) <- function( #' @param seed RNG seed for `ci_method = "boot"`; required for #' reproducibility of the bootstrap. #' @param width Size of the error bar caps as a fraction of the resolution -#' of the data (default 0.5). Ignored when `caps = FALSE`. +#' of the data. When `NULL` (default) the style token +#' `width_errorbar` (0.4) applies for `caps = TRUE`. Ignored when +#' `caps = FALSE`. #' @param orientation `"vertical"` (default) or `"horizontal"`. #' @param caps If `TRUE` (default), draw end caps; `FALSE` renders bare #' interval lines (`geom_linerange`). @@ -1657,7 +1679,7 @@ mark_errorbar <- S7::new_generic( function(plot, mapping = NULL, data = NULL, position = NULL, ..., stat = "identity", level = 0.95, ci_method = c("normal", "boot"), seed = NULL, - width = NULL, orientation = c("vertical", "horizontal"), + width = NULL, orientation = c("vertical", "horizontal"), caps = TRUE, rasterize = FALSE, rasterize_dpi = 300, rasterize_dev = "cairo") { S7::S7_dispatch() @@ -1681,7 +1703,11 @@ S7::method(mark_errorbar, plotit_class) <- function( params <- rlang::list2(...) params$orientation <- gg_orient # Cap width is an errorbar-only parameter; linerange has no caps. - if (isTRUE(caps)) params$width <- width + if (isTRUE(caps)) { + params$width <- width %||% ._MARK_STYLE$width_errorbar + } else { + params$width <- NULL + } if (!identical(stat, "identity")) { params$stat <- "summary" params$fun.data <- switch(stat, @@ -1791,13 +1817,9 @@ S7::method(mark_ribbon, plotit_class) <- function( alpha <- ._MARK_STYLE$alpha_ci } params$alpha <- alpha - if (is.null(width)) { - width <- ._MARK_STYLE$width_ribbon - } - if (is.null(width)) { - width <- ._MARK_STYLE$width_errorbar - } - params$width <- width + # geom_ribbon ignores width on continuous identity bands and warns; only + # pass it when the user asked, or on the discrete tile path below. + user_width <- width # Discrete axis + statistical entity: stat_summary collapses each ribbon # group to a single point (no band). Aggregate here instead and emit one @@ -1846,7 +1868,7 @@ S7::method(mark_ribbon, plotit_class) <- function( band_mapping$group <- rlang::sym("grp") } params$inherit.aes <- FALSE # the band carries its own data - params$width <- width + params$width <- user_width %||% ._MARK_STYLE$width_ribbon params$colour <- params$colour %||% NA params$linewidth <- 0 params$show.legend <- FALSE # the grouping legend lives on the marks @@ -1859,6 +1881,11 @@ S7::method(mark_ribbon, plotit_class) <- function( } } + if (!is.null(user_width)) { + params$width <- user_width + } else { + params$width <- NULL + } if (!identical(stat, "identity")) { params$stat <- "summary" params$fun.data <- switch(stat, diff --git a/R/mark_relational.R b/R/mark_relational.R index 7513f8f..c4562b8 100644 --- a/R/mark_relational.R +++ b/R/mark_relational.R @@ -525,7 +525,7 @@ S7::method(mark_network, plotit_class) <- function( seed = NULL, edge_color = ._MARK_STYLE$faint, edge_width = ._MARK_STYLE$lw_thin, edge_alpha = NULL, edge_shape = c("straight", "curved"), - node_color = ._MARK_STYLE$primary, node_size = ._MARK_STYLE$size_node, + node_color = ._MARK_STYLE$primary, node_size = ._MARK_STYLE$size_node, show_labels = TRUE, ... ) { layout <- match.arg(layout) diff --git a/R/mark_style.R b/R/mark_style.R index e44a173..b9dddcd 100644 --- a/R/mark_style.R +++ b/R/mark_style.R @@ -128,9 +128,10 @@ NULL ), mark_rule = list(colour = ._MARK_STYLE$soft, linewidth = ._MARK_STYLE$lw_thin), # tidyplots ff_errorbar: linewidth 0.25, width 0.4 + # width is method-injected only for caps=TRUE (geom_errorbar); geom_linerange + # rejects it, so it must not live in static defaults. mark_errorbar = list( - linewidth = ._MARK_STYLE$lw_data, - width = 0.4 + linewidth = ._MARK_STYLE$lw_data ), # tidyplots ff_ribbon: alpha 0.4, color = NA mark_ribbon = list(alpha = ._MARK_STYLE$alpha_ci, colour = NA), @@ -155,22 +156,21 @@ NULL # tidyplots ff_bar / ff_barstack zero the lower padding so bars sit flush # on the value axis instead of floating above a 5% expansion gap. -# Continuous value axis only; discrete axes keep ggplot2 expansion. +# Applied only when the pipeline has not already installed a position scale: +# a second scale_*() call would replace the user's trans/limits/breaks. #' Zero the lower expansion on the continuous value axis (tidyplots bars). #' @noRd #' @keywords internal ._flush_value_axis <- function(plot, axis = "y") { gg <- plot@gg - sc <- gg$scales$get_scales(axis) - has_sc <- !is.null(sc) - discrete <- has_sc && inherits(sc, c("ScaleDiscretePosition", "ScaleDiscrete")) - if (discrete) { + sc <- tryCatch(gg$scales$get_scales(axis), error = function(e) NULL) + # User-installed scale (discrete or continuous): leave it intact. + if (!is.null(sc)) { return(plot) } # Default continuous expansion is mult = c(0.05, 0.05); keep the upper # headroom for value labels (tidyplots padding = c(0, NA) -> upper 0.05). plot@gg <- gg + ggplot2::scale_y_continuous( - name = if (has_sc && !inherits(sc$name, "waiver")) sc$name else ggplot2::waiver(), expand = ggplot2::expansion(mult = c(0, 0.05)) ) plot diff --git a/R/plot.R b/R/plot.R index e4db9a1..59eb7e9 100644 --- a/R/plot.R +++ b/R/plot.R @@ -105,22 +105,27 @@ plotit <- function( # bar edges, point borders). Continuous fill (heatmap / corr magnitude) # is left alone: mirroring it would inject a colour quosure that layer # reshapes (Var1/Var2/value) cannot resolve. + # The mirrored twin is visual-only: its default scale is attached with + # guide="none" so one group variable never renders two legends. .is_disc_quo <- function(q, data) { col <- tryCatch(rlang::eval_tidy(q, data), error = function(e) NULL) !is.null(col) && (is.factor(col) || is.character(col) || is.logical(col)) } + mirrored_aes <- NULL if (has_color && !has_fill && .is_disc_quo(mapping$colour, data)) { mapping <- structure( utils::modifyList(mapping, list(fill = mapping$colour)), class = c("plotit_encode", "uneval") ) has_fill <- TRUE + mirrored_aes <- "fill" } else if (has_fill && !has_color && .is_disc_quo(mapping$fill, data)) { mapping <- structure( utils::modifyList(mapping, list(colour = mapping$fill)), class = c("plotit_encode", "uneval") ) has_color <- TRUE + mirrored_aes <- "colour" } # Inject I(default_color) to both colour and fill so that all geoms @@ -177,7 +182,8 @@ plotit <- function( # single-colour path, which owns its static brand blue. managed <- character(0) if (!graph_input && !use_default) { - p <- ._attach_default_colour_scale(p, data, mapping) + silent <- if (!is.null(mirrored_aes)) mirrored_aes else character(0) + p <- ._attach_default_colour_scale(p, data, mapping, silent_aes = silent) managed <- intersect(c("colour", "fill"), names(mapping)) } if (use_default) { diff --git a/R/style.R b/R/style.R index b85511f..5415f43 100644 --- a/R/style.R +++ b/R/style.R @@ -18,8 +18,12 @@ NULL #' @param ... Theme element overrides, passed to `ggplot2::theme()`. #' @param base_size Base font size in pts (default 7, tidyplots-calibrated). #' @param base_family Base font family (default `""` = system sans-serif). -#' @param base_theme A complete ggplot2 theme object to use instead of the -#' default (e.g., `ggplot2::theme_bw()`). `NULL` = use plotit default. +#' @param base_theme A complete ggplot2 theme *object* (e.g. +#' `ggplot2::theme_bw()`) or a theme *function* (e.g. +#' `ggplot2::theme_minimal`). `NULL` = use plotit default. When a +#' theme function is supplied, `base_size`/`base_family` are forwarded +#' to it; when a theme object is supplied together with font args, the +#' font args are ignored with a warning. #' @return Modified plotit object. #' @examples #' plotit(iris, encode(x = Sepal.Width, y = Sepal.Length)) |> @@ -43,8 +47,36 @@ S7::method(style, plotit_class) <- function( base_family = NULL, base_theme = NULL ) { - thm <- base_theme %||% ._theme_default(base_size, base_family) + thm <- ._resolve_style_theme(base_size, base_family, base_theme) plot@gg <- plot@gg + thm + ggplot2::theme(...) attr(plot@meta, "plotit_theme_managed") <- TRUE plot } + +# Shared by style() single-plot and composite methods. +# base_theme may be a theme object or a theme *function* (e.g. +# ggplot2::theme_minimal). Font args only apply when no complete +# base_theme object was supplied; otherwise they are warned and dropped. +#' Resolve the effective theme for style(). +#' @noRd +#' @keywords internal +._resolve_style_theme <- function(base_size, base_family, base_theme) { + if (!is.null(base_theme) && is.function(base_theme)) { + args <- list() + if (!is.null(base_size)) args$base_size <- base_size + if (!is.null(base_family)) args$base_family <- base_family + base_theme <- tryCatch( + do.call(base_theme, args), + error = function(e) base_theme() + ) + # Font args were consumed by the function call -- no warning. + return(base_theme) + } + if (!is.null(base_theme) && (!is.null(base_size) || !is.null(base_family))) { + ._warn_ignored( + "base_size/base_family", + "a complete base_theme was supplied; bake font size into base_theme or drop it." + ) + } + base_theme %||% ._theme_default(base_size, base_family) +} diff --git a/R/theme.R b/R/theme.R index 2db1554..e6e8ee2 100644 --- a/R/theme.R +++ b/R/theme.R @@ -172,7 +172,7 @@ NULL #' (identity scaling owns those). #' @noRd #' @keywords internal -._default_colour_scale <- function(aes_name, data_tbl, var) { +._default_colour_scale <- function(aes_name, data_tbl, var, guide = "legend") { col <- tryCatch(rlang::eval_tidy(var, data_tbl), error = function(e) NULL) if (is.null(col) || inherits(col, "AsIs")) { return(NULL) @@ -183,23 +183,35 @@ NULL # layer, which misinterprets plain palette functions. ggplot2::discrete_scale( aesthetics = aes_name, - palette = function(n) ._palette_discrete(n) + palette = function(n) ._palette_discrete(n), + guide = guide ) } else { - ._cf( + sc <- ._cf( aes_name, ggplot2::scale_colour_viridis_c, ggplot2::scale_fill_viridis_c )(option = ._STYLE_TOKENS$palette_continuous) + if (!identical(guide, "legend")) { + guides <- list(guide) + names(guides) <- aes_name + sc <- sc + do.call(ggplot2::guides, guides) + } + sc } } #' Attach default colour scales for mapped colour/fill aesthetics. +#' `silent_aes` get `guide = "none"` (mirrored colour/fill twins). #' @noRd #' @keywords internal -._attach_default_colour_scale <- function(p, data, mapping) { +._attach_default_colour_scale <- function(p, data, mapping, + silent_aes = character(0)) { for (aes_name in intersect(c("colour", "fill"), names(mapping))) { - sc <- ._default_colour_scale(aes_name, data, mapping[[aes_name]]) + guide <- if (aes_name %in% silent_aes) "none" else "legend" + sc <- ._default_colour_scale(aes_name, data, mapping[[aes_name]], + guide = guide + ) if (!is.null(sc)) { p <- p + sc } diff --git a/README.md b/README.md index 9cf2328..8f6f270 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # plotit -[![Lifecycle: experimental](https://img.shields.io/badge/lifecycle-experimental-orange.svg)](https://lifecycle.r-lib.org/articles/stages.html#experimental) +[![Lifecycle: maturing](https://img.shields.io/badge/lifecycle-maturing-blue.svg)](https://lifecycle.r-lib.org/articles/stages.html#maturing) [![R-CMD-check](https://github.com/zorrooz/plotit/actions/workflows/R-CMD-check.yaml/badge.svg)](https://github.com/zorrooz/plotit/actions/workflows/R-CMD-check.yaml) [![pkgdown](https://github.com/zorrooz/plotit/actions/workflows/pkgdown.yaml/badge.svg)](https://zorrooz.github.io/plotit/) [![lint](https://github.com/zorrooz/plotit/actions/workflows/lint.yaml/badge.svg)](https://github.com/zorrooz/plotit/actions/workflows/lint.yaml) @@ -9,11 +9,10 @@

简体中文 | English

-> ⚠️ **Early development stage.** -> plotit is under active, pre-release development. Breaking changes are -> **extremely likely** with every update. The API is incomplete, many -> planned features are missing, and bugs are expected. Do not use in -> production. Use at your own risk. Feedback and contributions are welcome. +> **plotit 1.0.0** — the declarative pipeline API surface (plotit/encode, +> mark_*/scale_*/layout_*/compose_*, style/export) is treated as the 1.0 +> contract. Minor releases may still refine defaults and documentation; +> report issues on GitHub. --- diff --git a/README_ZH.md b/README_ZH.md index f50b8af..b0006a1 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -1,7 +1,7 @@ # plotit -[![Lifecycle: experimental](https://img.shields.io/badge/lifecycle-experimental-orange.svg)](https://lifecycle.r-lib.org/articles/stages.html#experimental) +[![Lifecycle: maturing](https://img.shields.io/badge/lifecycle-maturing-blue.svg)](https://lifecycle.r-lib.org/articles/stages.html#maturing) [![R-CMD-check](https://github.com/zorrooz/plotit/actions/workflows/R-CMD-check.yaml/badge.svg)](https://github.com/zorrooz/plotit/actions/workflows/R-CMD-check.yaml) [![pkgdown](https://github.com/zorrooz/plotit/actions/workflows/pkgdown.yaml/badge.svg)](https://zorrooz.github.io/plotit/) [![lint](https://github.com/zorrooz/plotit/actions/workflows/lint.yaml/badge.svg)](https://github.com/zorrooz/plotit/actions/workflows/lint.yaml) @@ -9,10 +9,8 @@

简体中文 | English

-> ⚠️ **早期开发阶段** -> plotit 处于活跃的预发布开发中。每次更新都**极有可能**带来破坏性变更。 -> API 实现不完整,大量计划功能尚未实现,可能存在许多 bug。请勿用于生产环境。 -> 使用风险自负。欢迎反馈和贡献。 +> **plotit 1.0.0** — 声明式管道 API 表面(plotit/encode、mark_*/scale_*/layout_*/compose_*、 +> style/export)按 1.0 契约维护。次版本仍可能微调默认值与文档;问题请在 GitHub 反馈。 --- @@ -242,6 +240,7 @@ edges |> | `compose_grid()` | 网格排列 | | `compose_inset()` | 浮动嵌入 | | `compose_marginal()` | 散点 + 边际分布 | +| `compose_annot()` | 基图 + 任意侧附着条带(如树状图) | ### 主题 @@ -265,12 +264,12 @@ edges |> ## 文档 完整文档见 [zorrooz.github.io/plotit](https://zorrooz.github.io/plotit/), -含[关系类图表系统指南](https://zorrooz.github.io/plotit/articles/relational.html) -与图形画廊(分组、分布、关系、坐标系、关系图、组合与标注),见 **Articles → Gallery**。 +含 Get Started、Gallery(意图导向图例)、Advanced(组合/关系布局/扩展)与 API 系统页, +见站点 **Articles** 菜单。 ## 贡献 -plotit 处于早期开发阶段。欢迎在 [GitHub Issues](https://github.com/zorrooz/plotit/issues) +plotit 欢迎在 [GitHub Issues](https://github.com/zorrooz/plotit/issues) 上提交 bug 报告、功能请求和 Pull Request。 ## 许可证 diff --git a/_pkgdown.yml b/_pkgdown.yml index aa15a16..e8590b0 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -16,7 +16,7 @@ figures: fig.width: 5.2 fig.height: 4.0 dev.args: - background: white + bg: white title: plotit subtitle: Declarative Plotting with ggplot2 diff --git a/man/mark_errorbar.Rd b/man/mark_errorbar.Rd index 7f54fae..ed70e53 100644 --- a/man/mark_errorbar.Rd +++ b/man/mark_errorbar.Rd @@ -50,7 +50,9 @@ no implicit aggregation) or one of \code{"mean_sem"}, \code{"mean_sd"}, reproducibility of the bootstrap.} \item{width}{Size of the error bar caps as a fraction of the resolution -of the data (default 0.5). Ignored when \code{caps = FALSE}.} +of the data. When \code{NULL} (default) the style token +\code{width_errorbar} (0.4) applies for \code{caps = TRUE}. Ignored when +\code{caps = FALSE}.} \item{orientation}{\code{"vertical"} (default) or \code{"horizontal"}.} diff --git a/tests/testthat/test-compose.R b/tests/testthat/test-compose.R index 0e64248..1970ff8 100644 --- a/tests/testthat/test-compose.R +++ b/tests/testthat/test-compose.R @@ -113,6 +113,18 @@ test_that("[BDD] compose_grid nested: composite + single plot renders", { unlink(f) }) +test_that("[BDD] nested compose keeps inner composite annotations", { + inner <- compose_grid(.p1, .p2, nrow = 1) |> + label_title("INNER-TITLE") |> + label_caption("INNER-CAPTION") + outer <- compose_grid(inner, .p3, ncol = 1) + gg_inner <- plotit:::._prep_subplot_gg(inner) + ann <- gg_inner$patches$annotation + expect_false(is.null(ann)) + expect_equal(ann$title, "INNER-TITLE") + expect_equal(ann$caption, "INNER-CAPTION") +}) + # ===================================================================== # compose_inset # ===================================================================== @@ -495,6 +507,19 @@ test_that("[BDD] compose_annot four sides + gap render", { unlink(f) }) +test_that("compose_annot gap does not absorb spacer into base cell", { + strip_v <- .dend_strip(stats::hclust(dist(iris[, 1:4])), "up") + p <- .p1 |> compose_annot(top = strip_v, gap = 0.2) + design <- p@gg$patches$layout$design + # patch_area holds parallel vectors: first entry is the base plot + # With top + gapv + base, base must occupy row 3 only (not span 2:3) + expect_equal(as.integer(design$t[1]), 3L) + expect_equal(as.integer(design$b[1]), 3L) + # strip occupies the top row + expect_equal(as.integer(design$t[2]), 1L) + expect_equal(as.integer(design$b[2]), 1L) +}) + test_that("[BDD] compose_annot accepts explicit strip sizes (three states)", { s1 <- .dend_strip(stats::hclust(dist(iris[, 1:4])), "up") p1 <- .p1 |> compose_annot(top = s1, heights = grid::unit(0.6, "in")) @@ -528,6 +553,7 @@ test_that("[BDD] compose_marginal with a single side renders (regression)", { top <- plotit(iris, encode(x = Sepal.Width)) |> mark_histogram() c <- compose_marginal(main, top = top) expect_equal(c@layout$type, "marginal") + expect_equal(c@layout$sides, "top") f <- tempfile(fileext = ".png") expect_no_error(export(c, f, dpi = 72)) unlink(f) @@ -535,6 +561,30 @@ test_that("[BDD] compose_marginal with a single side renders (regression)", { expect_error(compose_marginal(main, top = top, align = "diagonal"), "must be one of") }) +test_that("compose_marginal default size scales only with present sides", { + main <- plotit(iris, encode(x = Sepal.Width, y = Sepal.Length, colour = Species)) |> + mark_point() + top <- plotit(iris, encode(x = Sepal.Width, fill = Species)) |> + mark_histogram(bins = 10) + right <- plotit(iris, encode(x = Sepal.Length, fill = Species)) |> + mark_histogram(bins = 10) |> + project_cartesian(flip = TRUE) + sz_top <- plotit:::._composite_default_size(compose_marginal(main, top = top)) + sz_both <- plotit:::._composite_default_size(compose_marginal(main, top = top, right = right)) + expect_lt(sz_top$width, sz_both$width) + expect_equal(sz_top$height, sz_both$height) +}) + +test_that("compose_grid design uses design geometry for default canvas", { + p <- .p1 + cmp <- compose_grid(p, p, p, p, design = "12\n34") + dims <- plotit:::._design_dims("12\n34", 4) + expect_equal(dims$ncol, 2L) + expect_equal(dims$nrow, 2L) + sz <- plotit:::._composite_default_size(cmp) + expect_true(sz$width > 0 && sz$height > 0) +}) + # ===================================================================== # stage 5: 5-3 D-05 -- flagship recipe prerequisites # - compose_annot + dendrogram strips verified above (5-2 tests) diff --git a/tests/testthat/test-export.R b/tests/testthat/test-export.R index c6ce4b0..9ec54c7 100644 --- a/tests/testthat/test-export.R +++ b/tests/testthat/test-export.R @@ -90,7 +90,7 @@ test_that("[BDD] panel size respects contract within +/-1%", { p <- plotit(mtcars, encode(x = wt, y = mpg), width = 5, height = 4, size_unit = "in" ) |> mark_point() - gt <- ._build_fixed_gtable(p@gg, 5, 4, "in") + gt <- plotit:::._build_fixed_gtable(p@gg, 5, 4, "in") pw <- grid::convertWidth(sum(gt$widths), "in", valueOnly = TRUE) ph <- grid::convertHeight(sum(gt$heights), "in", valueOnly = TRUE) expect_gt(pw, 5) diff --git a/tests/testthat/test-graph.R b/tests/testthat/test-graph.R index d9263c9..01a07ce 100644 --- a/tests/testthat/test-graph.R +++ b/tests/testthat/test-graph.R @@ -5,7 +5,7 @@ test_that("[BDD] as_graph: canonical edgelist generates implicit nodes and unit g <- as_graph(e) expect_s3_class(g, "plotit_graph") - expect_true(is_graph(g)) + expect_true(plotit:::is_graph(g)) expect_setequal(names(g), c("nodes", "edges")) expect_identical(g$nodes$id, c("a", "b", "c")) # first-appearance order expect_identical(g$edges$source, c("a", "a", "b")) @@ -113,7 +113,7 @@ test_that("[BDD] plotit accepts graph data and rejects global mappings", { g <- as_graph(data.frame(source = "a", target = "b")) p <- plotit(g) - expect_true(is_graph(p@graph)) + expect_true(plotit:::is_graph(p@graph)) expect_error(plotit(g, encode(x = id)), "mark level") expect_warning(plotit(g, default_color = "red"), "ignored") diff --git a/tests/testthat/test-label.R b/tests/testthat/test-label.R index a11bd9a..51116de 100644 --- a/tests/testthat/test-label.R +++ b/tests/testthat/test-label.R @@ -56,6 +56,15 @@ test_that("[BDD] label_title text=\"\" renders empty title", { expect_equal(built$plot$labels$title, "") }) +test_that("[BDD] label_title text=\"FALSE\" is text, not hide", { + p <- plotit(iris, encode(x = Sepal.Width, y = Sepal.Length)) |> + mark_point() |> + label_title(text = "FALSE") + built <- .build_synced(p) + expect_equal(built$plot$labels$title, "FALSE") + expect_false(inherits(built$plot$theme$plot.title, "element_blank")) +}) + # ---- label_subtitle ---- test_that("[BDD] label_subtitle sets rendered subtitle after sync", { p <- plotit(iris, encode(x = Sepal.Width, y = Sepal.Length)) |> diff --git a/tests/testthat/test-mark-extended.R b/tests/testthat/test-mark-extended.R index 39c4a19..a44dc5c 100644 --- a/tests/testthat/test-mark-extended.R +++ b/tests/testthat/test-mark-extended.R @@ -286,9 +286,21 @@ test_that("mark_errorbar vertical caps stay an errorbar", { test_that("mark_errorbar caps = FALSE uses linerange", { df <- data.frame(x = c("A", "B"), y = c(10, 20), ymin = c(8, 18), ymax = c(12, 22)) - p <- plotit(df, encode(x = x, y = y, ymin = ymin, ymax = ymax)) |> - mark_errorbar(caps = FALSE) + expect_no_warning( + p <- plotit(df, encode(x = x, y = y, ymin = ymin, ymax = ymax)) |> + mark_errorbar(caps = FALSE) + ) expect_true(inherits(p@gg$layers[[1]]$geom, "GeomLinerange")) + expect_null(p@gg$layers[[1]]$aes_params$width) + expect_null(p@gg$layers[[1]]$geom_params$width) +}) + +test_that("mark_errorbar caps = TRUE injects the style-token width", { + df <- data.frame(x = c("A", "B"), y = c(10, 20), ymin = c(8, 18), ymax = c(12, 22)) + p <- plotit(df, encode(x = x, y = y, ymin = ymin, ymax = ymax)) |> + mark_errorbar() + w <- p@gg$layers[[1]]$aes_params$width %||% p@gg$layers[[1]]$geom_params$width + expect_equal(w, plotit:::._MARK_STYLE$width_errorbar) }) test_that("mark_errorbar horizontal maps y position with xmin/xmax", { diff --git a/tests/testthat/test-mark-stat-entity.R b/tests/testthat/test-mark-stat-entity.R index 1f08f28..c07b60e 100644 --- a/tests/testthat/test-mark-stat-entity.R +++ b/tests/testthat/test-mark-stat-entity.R @@ -95,8 +95,10 @@ test_that("[BDD] mark_ribbon identity mode renders ymin/ymax band", { band <- data.frame( x = 1:5, ymin = c(1, 2, 3, 4, 5), ymax = c(3, 4, 5, 6, 7) ) - p <- plotit(band, encode(x = x, ymin = ymin, ymax = ymax)) |> - mark_ribbon() + expect_no_warning( + p <- plotit(band, encode(x = x, ymin = ymin, ymax = ymax)) |> + mark_ribbon() + ) b <- ggplot2::ggplot_build(p@gg) expect_true(inherits(p@gg$layers[[1]]$geom, "GeomRibbon")) expect_equal(b$data[[1]]$ymin, band$ymin) diff --git a/tests/testthat/test-mark.R b/tests/testthat/test-mark.R index a943349..5dd10b4 100644 --- a/tests/testthat/test-mark.R +++ b/tests/testthat/test-mark.R @@ -333,6 +333,18 @@ test_that("[BDD] mark_rule with x+xend+y+yend adds segment", { expect_true(length(built$data) >= 2) }) +test_that("mark_rule segment path shares the hline linewidth default", { + df <- data.frame(x = 1:5, y = 1:5) + ph <- plotit(df, encode(x = x, y = y)) |> mark_rule(yintercept = 3) + ps <- plotit(df, encode(x = x, y = y)) |> + mark_rule(x = 2, xend = 4, y = 2, yend = 4) + expect_equal( + ph@gg$layers[[1]]$aes_params$linewidth, + ps@gg$layers[[1]]$aes_params$linewidth + ) + expect_equal(ps@gg$layers[[1]]$aes_params$linewidth, plotit:::._MARK_STYLE$lw_thin) +}) + test_that("mark_rule supports rasterize", { skip_if_not_installed("ggrastr") p <- plotit(iris, encode(x = Sepal.Width, y = Sepal.Length)) |> @@ -495,6 +507,15 @@ test_that("make_mark warns on non-mark_ name", { expect_warning(make_mark("foo_bar", ggplot2::geom_point)) }) +test_that("make_mark registers under the requested mark_name (style defaults apply)", { + # Re-register a catalogue mark through the factory; defaults must still hit. + g <- plotit:::._make_mark_generic("mark_point") + plotit:::._register_mark_method(g, ggplot2::geom_point, mark_name = "mark_point") + p <- g(plotit(iris, encode(x = Sepal.Width, y = Sepal.Length))) + # mark_point default size is 1 (._MARK_DEFAULTS) + expect_equal(p@gg$layers[[1]]$aes_params$size, 1) +}) + test_that("make_theme creates a usable theme function", { style_test <- make_theme("style_test", plot.title = ggplot2::element_text(colour = "blue") diff --git a/tests/testthat/test-plot.R b/tests/testthat/test-plot.R index 9d2e379..5679661 100644 --- a/tests/testthat/test-plot.R +++ b/tests/testthat/test-plot.R @@ -10,6 +10,58 @@ test_that("plotit() and encode() cooperate to create valid object", { expect_true(inherits(p@gg, "ggplot")) }) +test_that("mark_bar preserves a pre-installed continuous y scale", { + p <- plotit(mtcars, encode(x = factor(cyl), y = mpg)) |> + scale_y(trans = "log10") |> + mark_bar() + sc <- p@gg$scales$get_scales("y") + expect_false(is.null(sc)) + expect_equal(sc$trans$name, "log-10") +}) + +test_that("mark_bar still flushes the lower expand when no y scale exists", { + p <- plotit(mtcars, encode(x = factor(cyl), y = mpg)) |> + mark_bar() + sc <- p@gg$scales$get_scales("y") + expect_false(is.null(sc)) + # tidyplots bar flush: lower mult 0, upper headroom 0.05 + exp <- sc$expand + expect_equal(as.numeric(exp[[1]]), 0) +}) + +test_that("discrete colour/fill mirroring suppresses the twin legend", { + p <- plotit(iris, encode(x = Sepal.Length, y = Sepal.Width, colour = Species)) |> + mark_point() + expect_true(all(c("colour", "fill") %in% names(p@gg$mapping))) + guides <- vapply(p@gg$scales$scales, function(sc) { + g <- sc$guide + if (is.null(g)) "legend" else as.character(g)[1] + }, character(1)) + names(guides) <- vapply( + p@gg$scales$scales, + function(sc) sc$aesthetics[[1]], + character(1) + ) + expect_equal(unname(guides[["colour"]]), "legend") + expect_equal(unname(guides[["fill"]]), "none") +}) + +test_that("user-owned fill still gets a legend when colour was mirrored the other way", { + p <- plotit(iris, encode(x = Sepal.Length, y = Sepal.Width, fill = Species)) |> + mark_boxplot() + guides <- vapply(p@gg$scales$scales, function(sc) { + g <- sc$guide + if (is.null(g)) "legend" else as.character(g)[1] + }, character(1)) + names(guides) <- vapply( + p@gg$scales$scales, + function(sc) sc$aesthetics[[1]], + character(1) + ) + expect_equal(unname(guides[["fill"]]), "legend") + expect_equal(unname(guides[["colour"]]), "none") +}) + test_that("plotit() rejects non-encode() mapping", { expect_error( plotit(iris, ggplot2::aes(x = Sepal.Width, y = Sepal.Length)), @@ -174,7 +226,7 @@ test_that("[BDD] fixed-aspect panels stay aspect-true in the export gtable", { ) build <- ggplot2::ggplot_build(p@gg) expected <- p@gg$coordinates$aspect(build$layout$panel_params[[1]]) - gt <- ._build_fixed_gtable(p@gg, 5, 3.5, "in") + gt <- plotit:::._build_fixed_gtable(p@gg, 5, 3.5, "in") panel_idx <- which(gt$layout$name == "panel")[1] w_in <- grid::convertWidth(gt$widths[[gt$layout$l[panel_idx]]], "in", valueOnly = TRUE) h_in <- grid::convertHeight(gt$heights[[gt$layout$t[panel_idx]]], "in", valueOnly = TRUE) diff --git a/tests/testthat/test-stage8-visual.R b/tests/testthat/test-stage8-visual.R index 89ccc79..d643ade 100644 --- a/tests/testthat/test-stage8-visual.R +++ b/tests/testthat/test-stage8-visual.R @@ -123,8 +123,12 @@ test_that("[BDD] T2.3 compose_inset parks its legend inside", { set.seed(2) d <- data.frame(x = rnorm(50), y = rnorm(50), g = rep(letters[1:3], length.out = 50)) cmp <- compose_inset( - d |> plotit(encode(x = x, y = y)) |> mark_point(alpha = 0.4), - d |> plotit(encode(x = x, y = y, colour = g)) |> mark_point() |> + d |> + plotit(encode(x = x, y = y)) |> + mark_point(alpha = 0.4), + d |> + plotit(encode(x = x, y = y, colour = g)) |> + mark_point() |> project_cartesian(xlim = c(-1, 1), ylim = c(-1, 1)), left = 0.55, bottom = 0.55, right = 0.95, top = 0.95 ) @@ -205,7 +209,9 @@ test_that("[BDD] T5.3 discrete variable + auto continuous scheme warns", { set.seed(4) d <- data.frame(x = rnorm(40), y = rnorm(40), g = factor(rep(letters[1:3], length.out = 40))) expect_warning( - d |> plotit(encode(x = x, y = y, colour = g)) |> mark_point() |> + d |> + plotit(encode(x = x, y = y, colour = g)) |> + mark_point() |> scale_color(range = "viridis"), "discrete", ignore.case = TRUE @@ -232,7 +238,8 @@ test_that("[BDD] T6 grouped boxes/bars on numeric x warn", { ) expect_warning( aggregate(y ~ numx + grp, d, mean) |> - plotit(encode(x = numx, y = y, fill = grp)) |> mark_bar(), + plotit(encode(x = numx, y = y, fill = grp)) |> + mark_bar(), "overlap", ignore.case = TRUE ) diff --git a/tests/testthat/test-style-contract.R b/tests/testthat/test-style-contract.R index 4dd74a7..1610009 100644 --- a/tests/testthat/test-style-contract.R +++ b/tests/testthat/test-style-contract.R @@ -54,7 +54,9 @@ testthat::test_that("[contract] mark tokens: strokes, alphas, widths", { testthat::expect_equal(d$mark_boxplot$staplewidth, 0.8) testthat::expect_equal(d$mark_boxplot$outlier.size, 0.5) testthat::expect_null(d$mark_boxplot$colour) # outline follows colour map - testthat::expect_equal(d$mark_errorbar$width, 0.4) + # width lives on the style token and is method-injected for caps=TRUE only + testthat::expect_equal(plotit:::._MARK_STYLE$width_errorbar, 0.4) + testthat::expect_null(d$mark_errorbar$width) testthat::expect_equal(d$mark_errorbar$linewidth, 0.25) testthat::expect_equal(d$mark_ribbon$alpha, 0.4) testthat::expect_true(is.na(d$mark_ribbon$colour)) diff --git a/tests/testthat/test-style.R b/tests/testthat/test-style.R index d71ff8d..5200601 100644 --- a/tests/testthat/test-style.R +++ b/tests/testthat/test-style.R @@ -86,3 +86,19 @@ test_that("[BDD] plotit() applies default theme automatically", { fill <- built$plot$theme$panel.background$fill expect_true(is.null(fill) || fill == "white" || identical(fill, "#FFFFFF")) }) + +test_that("style(base_theme= function) forwards base_size", { + p <- plotit(iris, encode(x = Sepal.Width, y = Sepal.Length)) |> + mark_point() |> + style(base_size = 14, base_theme = ggplot2::theme_minimal) + expect_equal(p@gg$theme$text$size, 14) +}) + +test_that("style(base_theme= object + base_size) warns and keeps object size", { + p0 <- plotit(iris, encode(x = Sepal.Width, y = Sepal.Length)) |> mark_point() + expect_warning( + p <- style(p0, base_size = 14, base_theme = ggplot2::theme_minimal(base_size = 10)), + "ignored" + ) + expect_equal(p@gg$theme$text$size, 10) +}) diff --git a/vignettes/api.Rmd b/vignettes/api.Rmd index 8c8ea89..7a78e87 100644 --- a/vignettes/api.Rmd +++ b/vignettes/api.Rmd @@ -53,7 +53,7 @@ pipe `|>` is the only composition operator. Single-plot verbs do not accept ```r plotit(data, mapping = encode(), autofit = FALSE, - width = 5, height = 3.5, size_unit = "in", + width = 89, height = 56, size_unit = "mm", dodge = NULL, default_color = "#0072B2") encode(...) # forwarded to aes(); returns plotit_encode