Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Package: animint2
Title: Animated Interactive Grammar of Graphics
Version: 2026.6.28
Version: 2026.7.29
URL: https://animint.github.io/animint2
BugReports: https://github.com/animint/animint2/issues
Authors@R: c(
Expand Down Expand Up @@ -287,6 +287,7 @@ Collate:
'uu_zzz.r'
'z_animint.R'
'z_animintHelpers.R'
'z_multiline.R'
'z_facets.R'
'z_geoms.R'
'z_helperFunctions.R'
Expand Down
10 changes: 7 additions & 3 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
# Changes in version 2026.7.29 (PR#261)

- Multi-line text support (issue #221): `\n` now works in plot titles, axis titles, legend titles, and `geom_text()` labels. R compiler converts newlines to `<br/>` via `R/z_multiline.R`; JavaScript renderer converts `<br/>` to SVG `<tspan>` elements.
- Fixed multiline text spacing: plot titles no longer overlap the plot area, and X/Y axis title spacing is consistent with single-line titles.
- Fixed axis titles to scale correctly with `theme(text=element_text(size=X))` (issue #64).

# Changes in version 2026.6.28 (PR#328)

- New tooltipID(), mouseMoved(), mousePressed(), mouseReleased() helper functions in tests/testthat/helper-functions.R for simulating mouse events and checking tooltip elements in renderer tests.
Expand All @@ -6,9 +12,7 @@

# Changes in version 2026.5.29 (PR#286)

- Positive `panel.margin` values in `"lines"` are supported for vertical and
horizontal `facet_grid` layouts and for `facet_wrap`. See
`inst/examples/panel-margin-issue-180.R`. Thanks @ANAMASGARD.
- Positive `panel.margin` values in `"lines"` are supported for vertical and horizontal `facet_grid` layouts and for `facet_wrap`. See `inst/examples/panel-margin-issue-180.R`. Thanks @ANAMASGARD.

# Changes in version 2026.4.28 (PR#292)

Expand Down
13 changes: 10 additions & 3 deletions R/z_animint.R
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ parsePlot <- function(meta, plot, plot.name){
for (xy in c("x", "y")) {
s <- function(tmp) sprintf(tmp, xy)
# one axis name per plot (ie, a xtitle/ytitle is shared across panels)
plot.info[[s("%stitle")]] <- if(is.blank(s("axis.title.%s"))){
axis_title_raw <- if(is.blank(s("axis.title.%s"))){
""
} else {
scale.i <- which(plot$scales$find(xy))
Expand All @@ -144,6 +144,10 @@ parsePlot <- function(meta, plot, plot.name){
lab.or.null
}
}
# Convert newlines to <br/> for multi-line axis titles (Issue #221)
plot.info[[s("%stitle")]] <- convertNewlinesToBreaks(axis_title_raw)
## axis title size.
plot.info[[s("%stitle_size")]] <- getTextSize(s("axis.title.%s"), theme.pars)
## panel text size.
plot.info[[s("strip_text_%ssize")]] <- getTextSize(
s("strip.text.%s"), theme.pars)
Expand Down Expand Up @@ -188,12 +192,13 @@ parsePlot <- function(meta, plot, plot.name){
# grab the unique axis labels (makes rendering simpler)
plot.info <- getUniqueAxisLabels(plot.info)

# grab plot title if present
plot.info$title <- if(is(theme.pars$plot.title, "blank")){
# grab plot title if present and convert newlines (Issue #221)
plot_title_raw <- if(is(theme.pars$plot.title, "blank")){
""
}else{
plot$labels$title
}
plot.info$title <- convertNewlinesToBreaks(plot_title_raw)
plot.info$title_size <- getTextSize("plot.title", theme.pars)

## Set plot width and height from animint.* options if they are
Expand Down Expand Up @@ -840,6 +845,8 @@ getLegendList <- function(plistextra){
}
}
legend.list <- lapply(gdefs, getLegend)
# Use the 'class' field from getLegend output for legend key names (Issue #221)
# This ensures JSON keys don't contain newlines or other special characters
names(legend.list) <- sapply(legend.list, function(i) i$class)
## Add a flag to specify whether or not there is both a color and a
## fill legend to display. If so, we need to draw the interior of
Expand Down
16 changes: 14 additions & 2 deletions R/z_animintHelpers.R
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,8 @@ getLegend <- function(mb){
names(data) <- paste0(geom, names(data))# aesthetics by geom
names(data) <- gsub(paste0(geom, "."), "", names(data), fixed=TRUE) # label isn't geom-specific
data$label <- paste(data$label) # otherwise it is AsIs.
# Convert newlines to <br/> for multi-line legend labels (Issue #221)
data$label <- convertNewlinesToBreaks(data$label)
data
}
dataframes <- mapply(function(i, j) cleanData(i$data, mb$key, j, i$params),
Expand All @@ -730,10 +732,16 @@ getLegend <- function(mb){
if(guidetype=="none"){
NULL
}else{
# Convert newlines to <br/> for multi-line legend title (Issue #221)
legend_title <- convertNewlinesToBreaks(mb$title)
# For the 'class' field (used as JSON key), sanitize newlines to spaces
# to avoid JSON parsing issues with control characters (Issue #221)
safe_title <- gsub("\n", " ", mb$title, fixed = TRUE)
legend_class <- if(mb$is.discrete) mb$selector else safe_title
list(guide = guidetype,
geoms = unlist(mb$geom.legend.list),
title = mb$title,
class = if(mb$is.discrete)mb$selector else mb$title,
title = legend_title,
class = legend_class,
selector = mb$selector,
is_discrete= mb$is.discrete,
legend_type = mb$legend_type,
Expand Down Expand Up @@ -917,6 +925,10 @@ split_recursive <- function(x, vars){
##' @author Toby Dylan Hocking
saveChunks <- function(x, meta){
if(is.data.frame(x)){
# Convert newlines to <br/> in label column for multi-line text (Issue #221)
if("label" %in% names(x)){
x$label <- convertNewlinesToBreaks(x$label)
}
this.i <- meta$chunk.i
csv.name <- sprintf("%s_chunk%d.tsv", meta$g$classed, this.i)
## Some geoms should be split into separate groups if there are NAs.
Expand Down
4 changes: 4 additions & 0 deletions R/z_multiline.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
convertNewlinesToBreaks <- function(text) {
gsub("\n", "<br/>", text, fixed = TRUE)
}

133 changes: 94 additions & 39 deletions inst/htmljs/animint.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,24 +180,61 @@ var animint = function (to_select, json_file) {
var measureText = function(pText, pFontSize, pAngle, pStyle) {
if (pText === undefined || pText === null || pText.length === 0) return {height: 0, width: 0};
if (pAngle === null || isNaN(pAngle)) pAngle = 0;


// Create temporary container to measure text
var container = element.append('svg');
// do we need to set the class so that styling is applied?
//.attr('class', classname);

container.append('text')
.attr({x: -1000, y: -1000})
var textElement = container.append('text')
.attr("transform", "rotate(" + pAngle + ")")
.attr("style", pStyle)
.attr("font-size", pFontSize)
.text(pText);

.attr("font-size", pFontSize);

// Check if text contains <br/> tags (multi-line)
var textStr = String(pText || '');
var lines = textStr.split('<br/>');

// Always use setMultilineText for consistent rendering
setMultilineText(textElement, pText);

// Get bounding box after rendering
var bbox = container.node().getBBox();

// Clean up temporary element
container.remove();


// Return measured dimensions
return {height: bbox.height, width: bbox.width};
};

// Set multi-line text on SVG text elements.
// Converts <br/> tags to <tspan> elements for proper SVG rendering.
var setMultilineText = function(textElement, text) {
textElement.each(function(d) {
var textStr = typeof text === 'function' ? text(d) : text;
// Check for null/undefined, but allow falsy values like 0 or ""
if (textStr === null || textStr === undefined) return;
var lines = String(textStr).split('<br/>');
var el = d3.select(this);
el.text('');
// Line height: 1.2em is standard SVG spacing between text lines
var lineHeight = 1.2;
var y = el.attr('y') || 0;
var x = el.attr('x') || 0;
// Get dominant-baseline from parent, if any
var dominantBaseline = el.attr('dominant-baseline');
lines.forEach(function(line, i) {
var tspan = el.append('tspan')
.attr('x', x)
.attr('dy', i === 0 ? 0 : lineHeight + 'em')
.text(line);
// Inherit dominant-baseline from parent text element if set
if (dominantBaseline) {
tspan.attr('dominant-baseline', dominantBaseline);
}
});
});
};


var nest_by_group = d3.nest().key(function(d){ return d.group; });
var dirs = json_file.split("/");
dirs.pop(); //if a directory path exists, remove the JSON file from dirs
Expand Down Expand Up @@ -373,9 +410,10 @@ var animint = function (to_select, json_file) {
var npanels = Math.max.apply(null, panel_names);

// Note axis names are "shared" across panels (just like the title)
var xtitlepadding = 5 + measureText(p_info["xtitle"], default_axis_px).height;
var ytitlepadding = 5 + measureText(p_info["ytitle"], default_axis_px).height;

var xtitle_size = p_info["xtitle_size"] || (default_axis_px + "pt");
var ytitle_size = p_info["ytitle_size"] || (default_axis_px + "pt");
var xtitlepadding = 5 + measureText(p_info["xtitle"], xtitle_size).height;
var ytitlepadding = 5 + measureText(p_info["ytitle"], ytitle_size).height;
// 'margins' are fixed across panels and do not
// include title/axis/label padding (since these are not
// fixed across panels). They do, however, account for
Expand Down Expand Up @@ -417,31 +455,42 @@ var animint = function (to_select, json_file) {
var titlepadding = measureText(p_info.title, p_info.title_size).height;
// why are we giving the title padding if it is undefined?
if (p_info.title === undefined) titlepadding = 0;

// Add extra margin below title for multiline text to prevent overlap
// with plot area. The measureText already accounts for multiline height,
// but we need additional bottom margin.
var titleBottomMargin = 5; // pixels of space below title

plotdim.title.x = p_info.options.width / 2;
plotdim.title.y = titlepadding;
svg.append("text")
.text(p_info.title)
// Position title at top margin, let it extend downward
plotdim.title.y = margin.top;
var titleText = svg.append("text")
.attr("class", "plottitle")
.attr("font-family", "sans-serif")
.attr("font-size", p_info.title_size)
.attr("transform", "translate(" + plotdim.title.x + "," +
plotdim.title.y + ")")
.style("text-anchor", "middle");
.style("text-anchor", "middle")
.attr("dominant-baseline", "hanging");
// Use multi-line text helper for plot titles (Issue #221)
setMultilineText(titleText, p_info.title);

// grab max text size over axis labels and facet strip labels
// Base spacing between tick labels and axis titles.
// Y-axis uses 5px base (tick labels extend horizontally).
// X-axis uses 30px base to account for rotated tick labels.
var axispaddingy = 5;
if(p_info.hasOwnProperty("ylabs") && p_info.ylabs.length){
axispaddingy += Math.max.apply(null, p_info.ylabs.map(function(entry){
// + 5 to give a little extra space to avoid bad axis labels
// in shiny.
// + 5 to give a little extra space to avoid bad axis labels in shiny.
return measureText(entry, p_info.ysize).width + 5;
}));
}
var axispaddingx = 30; // distance between tick marks and x axis name.
if(p_info.hasOwnProperty("xlabs") && p_info.xlabs.length){
// TODO: throw warning if text height is large portion of plot height?
axispaddingx += Math.max.apply(null, p_info.xlabs.map(function(entry){
return measureText(entry, p_info.xsize, p_info.xangle).height;
return measureText(entry, p_info.xsize, p_info.xangle).height;
}));
// TODO: carefully calculating this gets complicated with rotating xlabs
//margin.right += 5;
Expand Down Expand Up @@ -502,7 +551,7 @@ var animint = function (to_select, json_file) {
var graph_height = p_info.options.height -
nrows * (margin.top + margin.bottom) -
strip_height -
titlepadding - n_xaxes * axispaddingx - xtitlepadding;
titlepadding - titleBottomMargin - n_xaxes * axispaddingx - xtitlepadding;

// Impose the pixelated aspect ratio of the graph upon the width/height
// proportions calculated by the compiler. This has to be done on the
Expand Down Expand Up @@ -640,7 +689,7 @@ var animint = function (to_select, json_file) {
var strip_h = cum_height_per_row[current_row-1];
plotdim.ystart = current_row * plotdim.margin.top +
(current_row - 1) * plotdim.margin.bottom +
graph_height_cum + titlepadding + strip_h;
graph_height_cum + titlepadding + titleBottomMargin + strip_h;
// room for xaxis title should be distributed evenly across
// panels to preserve aspect ratio
plotdim.yend = plotdim.ystart + plotdim.graph.height;
Expand Down Expand Up @@ -842,30 +891,30 @@ var animint = function (to_select, json_file) {
} //end of for(layout_i
// After drawing all backgrounds, we can draw the axis labels.
if(p_info["ytitle"]){
svg.append("text")
.text(p_info["ytitle"])
var ytitleText = svg.append("text")
.attr("class", "ytitle")
.style("text-anchor", "middle")
.style("font-size", default_axis_px + "px")
.style("font-size", ytitle_size)
.attr("transform", "translate(" +
ytitle_x +
"," +
(ytitle_top + ytitle_bottom)/2 +
")rotate(270)")
;
")rotate(270)");
// Use multi-line text helper for y-axis title (Issue #221)
setMultilineText(ytitleText, p_info["ytitle"]);
}
if(p_info["xtitle"]){
svg.append("text")
.text(p_info["xtitle"])
var xtitleText = svg.append("text")
.attr("class", "xtitle")
.style("text-anchor", "middle")
.style("font-size", default_axis_px + "px")
.style("font-size", xtitle_size)
.attr("transform", "translate(" +
(xtitle_left + xtitle_right)/2 +
"," +
xtitle_y +
")")
;
")");
// Use multi-line text helper for x-axis title (Issue #221)
setMultilineText(xtitleText, p_info["xtitle"]);
}
Plots[p_name].scales = scales;
}; //end of add_plot()
Expand Down Expand Up @@ -1602,10 +1651,11 @@ var animint = function (to_select, json_file) {
.attr("y", toXY("y", "y"))
.attr("font-size", get_size)
.style("text-anchor", get_text_anchor)
.attr("transform", get_rotate)
.text(function (d) {
return d.label;
})
.attr("transform", get_rotate);
// Use multi-line text helper for geom_text labels (Issue #221)
setMultilineText(e, function (d) {
return d.label;
})
;
};
eAppend = "text";
Expand Down Expand Up @@ -2333,10 +2383,15 @@ var animint = function (to_select, json_file) {
var first_th = first_tr.append("th")
.attr("align", "left")
.attr("colspan", 2)
.text(l_info.title)
.attr("class", legend_class)
.style("font-size", l_info.title_size)
;
.style("font-size", l_info.title_size);
// Use multi-line text helper for legend title (Issue #221)
if (l_info.title && l_info.title.indexOf('<br/>') > -1) {
// Multi-line title: replace <br/> with actual line breaks in HTML
first_th.html(l_info.title.replace(/<br\/>/g, '<br/>'));
} else {
first_th.text(l_info.title);
}
var legend_svgs = legend_rows.append("td")
.append("svg")
.attr("id", function(d){return d["id"]+"_svg";})
Expand Down
Loading
Loading