Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,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
9 changes: 7 additions & 2 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@

# Changes in version 2025.10.22 (PR#266)

- `geom_text(vjust!=0)` warning mentions vjust support in `geom_label_aligned()`.

# Changes in version 2025.10.21 (PR#221)

- Multi-line text support: `\n` now works in plot titles, axis titles, legend titles, and `geom_text()` labels. Created `R/z_multiline.R` with helper to convert `\n` to `<br/>` during R compilation. JavaScript renderer converts `<br/>` to SVG `<tspan>` elements for proper multi-line display.

# Changes in version 2025.10.17 (PR#255)

- `getCommonChunk()` uses default group=1 (previously 1:N which was slower).
Expand Down Expand Up @@ -227,7 +232,7 @@

# Changes in 2022.5.25

- Add ability to rotate geom_text labels, following ggplot2's semantics of rotation direction.
- Add ability to rotate geom_text labels, following ggplot2\'s semantics of rotation direction.

# Changes in 2022.5.24

Expand Down Expand Up @@ -292,4 +297,4 @@

# Changes in 2017.08.24

- DSL: clickSelects/showSelected are now specified as parameters rather than aesthetics.
- DSL: clickSelects/showSelected are now specified as parameters rather than aesthetics.
13 changes: 9 additions & 4 deletions R/z_animint.R
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,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 @@ -143,6 +143,8 @@ 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)
## panel text size.
plot.info[[s("strip_text_%ssize")]] <- getTextSize(
s("strip.text.%s"), theme.pars)
Expand Down Expand Up @@ -187,12 +189,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 @@ -744,7 +747,6 @@ getLegendList <- function(plistextra){
gdefs <- guides_merge(gdefs)
gdefs <- guides_geom(gdefs, layers, default_mapping)
} else (zeroGrob())
names(gdefs) <- sapply(gdefs, function(i) i$title)

## adding the variable used to each LegendList
for(leg in seq_along(gdefs)) {
Expand Down Expand Up @@ -824,6 +826,9 @@ 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
## the points in the color legend as the same color.
Expand Down
16 changes: 14 additions & 2 deletions R/z_animintHelpers.R
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,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 @@ -710,10 +712,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 @@ -897,6 +905,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)
}

135 changes: 92 additions & 43 deletions inst/htmljs/animint.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,24 +125,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;
if (!textStr) return;

var lines = String(textStr).split('<br/>');
var el = d3.select(this);

// Clear existing content
el.text('');

// Always use <tspan> elements for consistent rendering
var lineHeight = 1.2; // em units
var y = el.attr('y') || 0;
var x = el.attr('x') || 0;

lines.forEach(function(line, i) {
el.append('tspan')
.attr('x', x)
.attr('dy', i === 0 ? 0 : lineHeight + 'em')
.text(line);
});
});
};

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 @@ -336,18 +373,29 @@ 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)
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 + ")")
.attr("x", plotdim.title.x)
.attr("y", plotdim.title.y)
.attr("transform", "translate(0,0)")
.style("text-anchor", "middle");
// 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
// Use consistent base spacing for both axes (distance from ticks to axis title)
var axis_label_base_spacing = 30;

var axispaddingy = 5;
if(p_info.hasOwnProperty("ylabs") && p_info.ylabs.length){
axispaddingy += Math.max.apply(null, p_info.ylabs.map(function(entry){
Expand All @@ -356,7 +404,7 @@ var animint = function (to_select, json_file) {
return measureText(entry, p_info.ysize).width + 5;
}));
}
var axispaddingx = 30; // distance between tick marks and x axis name.
var axispaddingx = axis_label_base_spacing; // 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){
Expand Down Expand Up @@ -421,7 +469,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 @@ -559,7 +607,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 @@ -765,30 +813,25 @@ 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")
.attr("x", ytitle_x)
.attr("y", (ytitle_top + ytitle_bottom)/2)
.attr("transform", "translate(0,0)rotate(270," + ytitle_x + "," + (ytitle_top + ytitle_bottom)/2 + ")")
.style("text-anchor", "middle")
.style("font-size", default_axis_px + "px")
.attr("transform", "translate(" +
ytitle_x +
"," +
(ytitle_top + ytitle_bottom)/2 +
")rotate(270)")
;
.style("font-size", default_axis_px + "px");
// 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")
.attr("x", (xtitle_left + xtitle_right)/2)
.attr("y", xtitle_y)
.style("text-anchor", "middle")
.style("font-size", default_axis_px + "px")
.attr("transform", "translate(" +
(xtitle_left + xtitle_right)/2 +
"," +
xtitle_y +
")")
;
.style("font-size", default_axis_px + "px");
// 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 @@ -1497,10 +1540,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 @@ -1746,7 +1790,7 @@ var animint = function (to_select, json_file) {
"stroke": get_colour_off,
"fill": get_fill_off
};
// TODO cleanup.
// TODO cleanup.
var select_style_default = ["opacity","stroke","fill"];
g_info.select_style = select_style_default.filter(
X => g_info.style_list.includes(X));
Expand Down Expand Up @@ -2210,10 +2254,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