Skip to content

Commit 50c81db

Browse files
Copilotyihui
andauthored
Add Quarto and Jupyter (R kernel) rendering support (#39)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: yihui <163582+yihui@users.noreply.github.com> Co-authored-by: Yihui Xie <xie@yihui.name>
1 parent 9380c14 commit 50c81db

13 files changed

Lines changed: 710 additions & 36 deletions

.Rbuildignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,6 @@
88
^package-lock\.json$
99
^examples$
1010
^G2$
11+
^tests/.*\.qmd$
12+
^tests/.*\.ipynb$
13+
^tests/.*\.py$

.github/copilot-instructions.md

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,46 @@ Before submitting changes:
191191
appears more than once, factor it into a shared helper function. This
192192
applies to expressions, patterns, and multi-line blocks alike.
193193

194-
### Variables Are Character Strings
194+
### Variables and Formula Interface
195195

196-
gglite does **NOT** use non-standard evaluation (NSE). Variables are specified
197-
as character strings, e.g., `g2(mtcars, x = 'mpg', y = 'hp')`.
196+
gglite does **NOT** use non-standard evaluation (NSE). Variables can be
197+
specified either as character strings (`x = 'mpg'`) or via the formula
198+
interface (`y ~ x`). **Prefer the formula interface** in examples and
199+
documentation because it is more concise and readable:
200+
201+
```r
202+
# Preferred: formula interface
203+
g2(mtcars, hp ~ mpg)
204+
205+
# Also valid: character strings
206+
g2(mtcars, x = 'mpg', y = 'hp')
207+
```
208+
209+
For single-variable distributions, omit the LHS:
210+
```r
211+
g2(mtcars, ~ mpg) # histogram
212+
g2(mtcars, ~ cyl) # histogram (cyl is numeric in mtcars)
213+
```
214+
215+
The formula interface also works for other aesthetic channels by passing a
216+
one-sided formula as a named argument:
217+
```r
218+
g2(iris, Sepal.Length ~ Sepal.Width, color = ~ Species)
219+
g2(mtcars, hp ~ mpg, color = ~ cyl, size = ~ wt)
220+
g2(iris, Sepal.Length ~ Sepal.Width, shape = ~ Species)
221+
```
222+
223+
**Drop explicit marks that can be automatically inferred.** gglite's
224+
`auto_mark()` detects the appropriate mark from the data types. Only specify
225+
a mark explicitly when you need a non-default one:
226+
227+
```r
228+
# Preferred: auto-inferred scatter plot
229+
g2(mtcars, hp ~ mpg)
230+
231+
# Only do this when you need something non-default
232+
g2(mtcars, hp ~ mpg) |> mark_line()
233+
```
198234

199235
### Testing Conventions
200236

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
name: Test Quarto and Jupyter examples
2+
3+
on:
4+
schedule:
5+
- cron: '0 0 * * 0' # weekly on Sunday at midnight UTC
6+
workflow_dispatch:
7+
push:
8+
branches:
9+
- main
10+
paths:
11+
- 'tests/*.qmd'
12+
- 'tests/*.ipynb'
13+
- 'tests/*.py'
14+
pull_request:
15+
paths:
16+
- 'tests/*.qmd'
17+
- 'tests/*.ipynb'
18+
- 'tests/*.py'
19+
20+
permissions:
21+
contents: write
22+
pull-requests: write
23+
24+
jobs:
25+
test:
26+
runs-on: ubuntu-latest
27+
28+
steps:
29+
- uses: actions/checkout@HEAD
30+
31+
- uses: r-lib/actions/setup-r@HEAD
32+
with:
33+
use-public-rspm: true
34+
35+
- uses: yihui/actions/setup-r-dependencies@HEAD
36+
with:
37+
extra-packages: . IRkernel rmarkdown
38+
39+
- name: Install Quarto
40+
uses: quarto-dev/quarto-actions/setup@HEAD
41+
42+
- name: Install Jupyter and IRkernel
43+
run: |
44+
pip install jupyter nbconvert playwright
45+
python -m playwright install chromium
46+
Rscript -e "IRkernel::installspec()"
47+
48+
- name: Render Quarto example
49+
run: quarto render tests/test-gglite.qmd
50+
51+
- name: Execute Jupyter notebook
52+
run: |
53+
jupyter nbconvert --to notebook --execute tests/test-gglite.ipynb \
54+
--output /tmp/test-gglite-executed.ipynb
55+
56+
- name: Browser-test Quarto output
57+
run: python tests/test-quarto.py tests/test-gglite.html
58+
env:
59+
SCREENSHOT_PATH: /tmp/quarto-test.png
60+
61+
- name: Browser-test Jupyter output
62+
run: python tests/test-jupyter.py /tmp/test-gglite-executed.ipynb
63+
env:
64+
SCREENSHOT_PATH: /tmp/jupyter-test.png
65+
66+
- name: Open PR to update notebook if outputs changed (main only)
67+
if: github.ref == 'refs/heads/main'
68+
env:
69+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
70+
run: |
71+
python tests/normalize-notebook.py \
72+
/tmp/test-gglite-executed.ipynb /tmp/test-gglite-normalized.ipynb
73+
if diff -q tests/test-gglite.ipynb /tmp/test-gglite-normalized.ipynb > /dev/null 2>&1; then
74+
echo "Notebook outputs unchanged."
75+
else
76+
cp /tmp/test-gglite-normalized.ipynb tests/test-gglite.ipynb
77+
BRANCH="auto/update-notebook-${{ github.run_id }}"
78+
git config user.email "github-actions[bot]@users.noreply.github.com"
79+
git config user.name "github-actions[bot]"
80+
git checkout -b "$BRANCH"
81+
git add tests/test-gglite.ipynb
82+
git commit -m "Update notebook outputs"
83+
git push origin "$BRANCH"
84+
gh pr create \
85+
--title "Update notebook outputs" \
86+
--body "Automated update of notebook cell outputs after execution on main." \
87+
--base main --head "$BRANCH"
88+
fi
89+
90+
- name: Upload artifacts
91+
if: always()
92+
uses: actions/upload-artifact@HEAD
93+
with:
94+
name: test-outputs
95+
path: |
96+
/tmp/*.png
97+
tests/test-gglite.html
98+
/tmp/test-gglite-executed.ipynb

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@ examples/*.html
99
G2/
1010
node_modules/
1111
vignettes/*.html
12+
tests/*.html
13+
tests/*_files/

DESCRIPTION

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
Package: gglite
22
Title: Lightweight Data Visualization via the Grammar of Graphics
3-
Version: 0.0.21
3+
Version: 0.0.22
44
Authors@R: person("Yihui", "Xie", role = c("aut", "cre"), email = "xie@yihui.name",
55
comment = c(ORCID = "0000-0003-0645-5666"))
66
Description: A lightweight R interface to the AntV G2 JavaScript visualization
7-
library with a ggplot2-style API. Supports rendering in R Markdown, Shiny,
8-
and standalone HTML previews. Depends only on 'xfun' for JSON serialization.
7+
library with a ggplot2-style API. Supports rendering in litedown, R Markdown,
8+
Quarto, Jupyter notebooks (via the R kernel), Shiny, and standalone HTML
9+
previews.
910
License: MIT + file LICENSE
1011
URL: https://github.com/yihui/gglite
1112
BugReports: https://github.com/yihui/gglite/issues
@@ -16,6 +17,7 @@ Suggests:
1617
htmltools,
1718
litedown,
1819
knitr,
20+
repr,
1921
shiny,
2022
testit
2123
VignetteBuilder: litedown

R/render.R

Lines changed: 73 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,9 @@ auto_mark = function(data, aesthetics, ts = FALSE) {
6969
list(list(type = 'line'))
7070
} else if (xt == 'numeric' && yt == 'none') {
7171
list(list(
72-
type = 'interval', transform = list(list(type = 'binX', y = 'count'))
72+
type = 'rect',
73+
transform = list(list(type = 'binX', y = 'count')),
74+
style = list(stroke = 'white')
7375
))
7476
} else if (xt == 'categorical' && yt == 'none') {
7577
list(list(
@@ -303,45 +305,77 @@ cdn_scripts = function() {
303305
sprintf('<script src="%s" defer></script>', c(g2_cdn(), g2_patches_cdn))
304306
}
305307

308+
g2_html_page = function(body) {
309+
paste(c(
310+
'<!DOCTYPE html>', '<html>', '<head>',
311+
'<meta charset="utf-8">',
312+
cdn_scripts(),
313+
'</head>', '<body>',
314+
body,
315+
'</body>', '</html>'
316+
), collapse = '\n')
317+
}
318+
306319
#' Preview a Chart in the Viewer or Browser
307320
#'
308321
#' @param x A `g2` object.
309322
#' @param ... Additional arguments passed to [chart_html()].
310323
#' @return The chart object (invisibly).
311324
#' @export
312325
print.g2 = function(x, ...) {
313-
body = chart_html(x, ...)
314-
html = c(
315-
'<!DOCTYPE html>', '<html>', '<head>',
316-
'<meta charset="utf-8">',
317-
cdn_scripts(),
318-
'</head>', '<body>',
319-
body,
320-
'</body>', '</html>'
321-
)
322326
#TODO: xfun >= 0.57.3 no longer needs paste()
323-
xfun::html_view(paste(html, collapse = '\n'))
327+
xfun::html_view(g2_html_page(chart_html(x, ...)))
324328
invisible(x)
325329
}
326330

331+
# Document-scoped flag stored in opts_knit to include CDN scripts only once per
332+
# knit session. opts_knit is restored between documents, so the flag resets
333+
# automatically, which also allows Quarto (which rejects non-disk-based
334+
# htmltools::htmlDependency sources).
335+
.knitr.flag = 'gglite.scripts_added'
336+
327337
#' Custom Printing in Knitr
328338
#'
329339
#' @param x A `g2` object.
330340
#' @param ... Ignored.
331341
#' @return A `knit_asis` character vector.
332342
knit_print.g2 = function(x, ...) {
333-
if (requireNamespace('htmltools', quietly = TRUE)) {
334-
dep = htmltools::htmlDependency(
335-
name = 'antv-g2', version = '5',
336-
src = c(href = ''),
337-
head = paste(cdn_scripts(), collapse = '\n')
338-
)
339-
knitr::knit_meta_add(list(dep))
340-
structure(chart_html(x), class = c('knit_asis', 'html'))
341-
} else {
342-
out = paste(c(cdn_scripts(), chart_html(x)), collapse = '\n')
343-
structure(out, class = c('knit_asis', 'html'))
343+
html = chart_html(x)
344+
if (!isTRUE(knitr::opts_knit$get(.knitr.flag))) {
345+
knitr::opts_knit$set(setNames(list(TRUE), .knitr.flag))
346+
html = paste(c(cdn_scripts(), html), collapse = '\n')
344347
}
348+
structure(html, class = c('knit_asis', 'html'))
349+
}
350+
351+
#' HTML Representation for Jupyter Notebooks
352+
#'
353+
#' Called by the `repr` package (used by IRkernel) to render g2 charts in
354+
#' Jupyter notebooks. Returns a complete HTML page so the chart is displayed
355+
#' inside a sandboxed output cell.
356+
#'
357+
#' @param obj A `g2` object.
358+
#' @param ... Ignored.
359+
#' @return A character string of complete HTML.
360+
#' @noRd
361+
repr_html.g2 = function(obj, ...) g2_html_page(chart_html(obj))
362+
363+
#' Text Representation for Jupyter Notebooks
364+
#'
365+
#' Returns a brief text description so IRkernel's MIME bundle includes a
366+
#' non-empty `text/plain` entry, which is required before any rich display
367+
#' (including HTML) is sent to the Jupyter frontend.
368+
#'
369+
#' @param obj A `g2` object.
370+
#' @param ... Ignored.
371+
#' @return A character string.
372+
#' @noRd
373+
repr_text.g2 = function(obj, ...) {
374+
n = if (is.data.frame(obj$data)) nrow(obj$data) else NULL
375+
marks = paste(vapply(obj$layers, `[[`, '', 'type'), collapse = ', ')
376+
if (!nzchar(marks)) marks = 'auto'
377+
n_str = if (is.null(n)) 'no data' else as.character(n)
378+
sprintf('G2 chart (%s; %s rows)', marks, n_str)
345379
}
346380

347381
#' @importFrom xfun record_print
@@ -350,11 +384,24 @@ record_print.g2 = function(x, ...) {
350384
xfun::new_record(c(cdn_scripts(), chart_html(x, ...), ''), 'asis')
351385
}
352386

353-
register_knit_print = function() {
354-
registerS3method('knit_print', 'g2', knit_print.g2, envir = asNamespace('knitr'))
387+
register_methods = function(pkgs, generics) {
388+
for (i in seq_along(pkgs)) local({
389+
pkg = pkgs[[i]]; generic = generics[[i]]
390+
hook = function(...) {
391+
registerS3method(
392+
generic, 'g2',
393+
asNamespace('gglite')[[paste0(generic, '.g2')]],
394+
envir = asNamespace(pkg)
395+
)
396+
}
397+
if (isNamespaceLoaded(pkg)) hook()
398+
setHook(packageEvent(pkg, 'onLoad'), hook)
399+
})
355400
}
356401

357402
.onLoad = function(...) {
358-
if (isNamespaceLoaded('knitr')) register_knit_print()
359-
setHook(packageEvent('knitr', 'onLoad'), function(...) register_knit_print())
403+
register_methods(
404+
c('knitr', 'repr', 'repr'),
405+
c('knit_print', 'repr_html', 'repr_text')
406+
)
360407
}

tests/normalize-notebook.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Normalize and copy an executed Jupyter notebook.
2+
3+
Strips execution metadata (execution counts, timestamps) that change between
4+
runs and copies the normalized notebook to the target path. Used by CI to
5+
produce a clean diff when deciding whether to open a PR.
6+
7+
Usage: python tests/normalize-notebook.py <source.ipynb> <target.ipynb>
8+
"""
9+
import json, sys
10+
11+
12+
def normalize(nb: dict) -> None:
13+
for cell in nb.get('cells', []):
14+
cell['execution_count'] = None
15+
cell.get('metadata', {}).pop('execution', None)
16+
for out in cell.get('outputs', []):
17+
out.pop('execution_count', None)
18+
19+
20+
if __name__ == '__main__':
21+
src, dst = sys.argv[1], sys.argv[2]
22+
with open(src) as f:
23+
nb = json.load(f)
24+
normalize(nb)
25+
with open(dst, 'w') as f:
26+
json.dump(nb, f, indent=1)
27+
f.write('\n')

0 commit comments

Comments
 (0)