diff --git a/DESCRIPTION b/DESCRIPTION index cdd8e0a33..e35dd810d 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: animint2 Title: Animated Interactive Grammar of Graphics -Version: 2025.12.3 +Version: 2025.12.4 URL: https://animint.github.io/animint2 BugReports: https://github.com/animint/animint2/issues Authors@R: c( diff --git a/NEWS.md b/NEWS.md index ec55e63d8..59b54ce27 100644 --- a/NEWS.md +++ b/NEWS.md @@ -10,6 +10,10 @@ - `update_axes`: Fixed issue #273 where axis tick text font-size was inconsistent between plots with and without `update_axes`. Previously, plots using `theme_animint(update_axes="x")` would lose `theme(axis.text = element_text(size=...))` styling after axis updates. +# Changes in version 2025.12.4 (PR#272) + +- Download status table now shows `files`, `disk`, and `rows` columns in "downloaded / total" format. Row counts display with comma separators for readability. Disk sizes show KiB or MiB units (using binary 1024 divisor, consistent with `man du`). Chunk sizes are calculated in R using `file.size()` and exported via plot.json. + # Changes in version 2025.10.31 (PR#271) - `geom_point()` now warns when shape parameter is set to a value other than 21, since animint2 web rendering only supports shape=21 for proper display of both color and fill aesthetics. diff --git a/R/geom-.r b/R/geom-.r index 92e2b8a3b..f7707b322 100644 --- a/R/geom-.r +++ b/R/geom-.r @@ -632,6 +632,14 @@ Geom <- gganimintproto("Geom", data.table::fwrite( data.or.null$common, file = tsv.path, row.names = FALSE, sep = "\t") + # Track common chunk size and rows + if(!exists("chunk_info", envir=meta)) { + meta$chunk_info <- list() + } + meta$chunk_info[[tsv.name]] <- list( + bytes = file.size(tsv.path), + rows = nrow(data.or.null$common) + ) data.or.null$varied } list(g=g, g.data.varied=g.data.varied, timeValues=AnimationInfo$timeValues) diff --git a/R/z_animint.R b/R/z_animint.R index 3beb7dd7c..539589701 100644 --- a/R/z_animint.R +++ b/R/z_animint.R @@ -212,8 +212,21 @@ storeLayer <- function(meta, g, g.data.varied){ ## Save each variable chunk to a separate tsv file. meta$chunk.i <- 1L meta$g <- g + # Initialize chunk_info only if it doesn't exist (common chunk may have been saved) + if(!exists("chunk_info", envir=meta)) { + meta$chunk_info <- list() + } g$chunks <- saveChunks(g.data.varied, meta) g$total <- length(unlist(g$chunks)) + + ## Add chunk size information to geom - filter to only this geom's chunks + g$chunk_info <- list() + geom_prefix <- paste0(g$classed, "_chunk") + for(chunk_name in names(meta$chunk_info)) { + if(startsWith(chunk_name, geom_prefix)) { + g$chunk_info[[chunk_name]] <- meta$chunk_info[[chunk_name]] + } + } ## Finally save to the master geom list. meta$geoms[[g$classed]] <- g diff --git a/R/z_animintHelpers.R b/R/z_animintHelpers.R index f36864547..1de93360b 100644 --- a/R/z_animintHelpers.R +++ b/R/z_animintHelpers.R @@ -917,9 +917,21 @@ saveChunks <- function(x, meta){ # fwrite defaults ensure fields are quoted so that embedded # newlines or tabs in string fields do not break the TSV format # when read by d3.tsv. + csv.path <- file.path(meta$out.dir, csv.name) data.table::fwrite( - na.omit(x), file.path(meta$out.dir, csv.name), + na.omit(x), csv.path, row.names=FALSE, sep="\t") + # Calculate chunk size and row count + chunk_bytes <- file.size(csv.path) + chunk_rows <- nrow(na.omit(x)) + # Store chunk info + if(!exists("chunk_info", envir=meta)) { + meta$chunk_info <- list() + } + meta$chunk_info[[csv.name]] <- list( + bytes = chunk_bytes, + rows = chunk_rows + ) meta$chunk.i <- meta$chunk.i + 1L this.i }else if(is.list(x)){ diff --git a/inst/htmljs/animint.js b/inst/htmljs/animint.js index 04c214300..6c6fcfe97 100644 --- a/inst/htmljs/animint.js +++ b/inst/htmljs/animint.js @@ -9,6 +9,55 @@ var animint = function (to_select, json_file) { var default_axis_px = 16; var grid_layout = false; var grid_layout_table; + + // Helper function to format numbers with commas (e.g., 4321 -> "4,321") + function formatWithCommas(num) { + return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); + } + + // Helper function to format bytes as KiB or MiB with appropriate precision + // Uses binary units (1024) consistent with "man du" documentation + function formatBytes(bytes) { + if (bytes === 0) return "0"; + var kib = bytes / 1024; + if (kib < 1024) { + // Less than 1 MiB, show in KiB + if (kib < 10) { + return kib.toFixed(2) + " KiB"; + } else if (kib < 100) { + return kib.toFixed(1) + " KiB"; + } else { + return Math.round(kib) + " KiB"; + } + } else { + // 1 MiB or more, show in MiB + var mib = kib / 1024; + return mib.toFixed(2) + " MiB"; + } + } + + // Helper function to apply consistent border and padding styles to table cells + function applyCellStyles(cell) { + return cell.style("border", "1px solid #ddd").style("padding", "4px"); + } + + // Helper function to update download status display after a chunk is downloaded + // Used by both common chunk and regular chunk download handlers + function updateDownloadStatus(g_info, tsv_name) { + if(g_info.chunk_info && g_info.chunk_info[tsv_name]){ + var info = g_info.chunk_info[tsv_name]; + g_info.total_bytes += info.bytes; + g_info.total_rows += info.rows; + g_info.downloaded_chunks += 1; + // Update display with "downloaded / total" format + var downloaded_count = g_info.downloaded_chunks; + var total_count = g_info.total_possible_chunks; + g_info.td_files.text(downloaded_count + " / " + total_count); + g_info.td_disk.text(formatBytes(g_info.total_bytes) + " / " + formatBytes(g_info.possible_bytes)); + g_info.td_rows.text(formatWithCommas(g_info.total_rows) + " / " + formatWithCommas(g_info.possible_rows)); + } + } + function wait_until_then(timeout, condFun, readyFun) { var args=arguments function checkFun() { @@ -224,11 +273,35 @@ var animint = function (to_select, json_file) { } // Add a row to the loading table. g_info.tr = Widgets["loading"].append("tr"); - g_info.tr.append("td").text(g_name); - g_info.tr.append("td").attr("class", "chunk"); - g_info.tr.append("td").attr("class", "downloaded").text(0); - g_info.tr.append("td").text(g_info.total); - g_info.tr.append("td").attr("class", "status").text("initialized"); + applyCellStyles(g_info.tr.append("td").text(g_name)); + g_info.td_files = applyCellStyles(g_info.tr.append("td").attr("class", "files").style("text-align", "right")); + g_info.td_disk = applyCellStyles(g_info.tr.append("td").attr("class", "disk").style("text-align", "right")); + g_info.td_rows = applyCellStyles(g_info.tr.append("td").attr("class", "rows").style("text-align", "right")); + // Initialize size tracking + g_info.total_bytes = 0; + g_info.total_rows = 0; + g_info.downloaded_chunks = 0; + // Calculate total possible bytes and rows from chunk_info + g_info.possible_bytes = 0; + g_info.possible_rows = 0; + g_info.total_possible_chunks = g_info.total; + if(g_info.chunk_info){ + var tsv_count = 0; + for(var chunk_name in g_info.chunk_info){ + if(chunk_name.endsWith('.tsv')){ + g_info.possible_bytes += g_info.chunk_info[chunk_name].bytes; + g_info.possible_rows += g_info.chunk_info[chunk_name].rows; + tsv_count++; + } + } + // chunk_info includes the common chunk, so total_possible_chunks should include it + g_info.total_possible_chunks = tsv_count; + } + + // Set initial display values + g_info.td_files.text("0 / " + g_info.total_possible_chunks); + g_info.td_disk.text("0 / " + formatBytes(g_info.possible_bytes)); + g_info.td_rows.text("0 / " + formatWithCommas(g_info.possible_rows)); // load chunk tsv g_info.data = {}; @@ -243,6 +316,8 @@ var animint = function (to_select, json_file) { d3.tsv(common_path, function (error, response) { var converted = convert_R_types(response, g_info.types); g_info.data[common_tsv] = nest_by_group.map(converted); + // Track common chunk download for size information + updateDownloadStatus(g_info, common_tsv); }); } else { g_info.common_tsv = null; @@ -993,8 +1068,9 @@ var animint = function (to_select, json_file) { }); var chunk = nest.map(response); g_info.data[tsv_name] = chunk; - g_info.tr.select("td.downloaded").text(d3.keys(g_info.data).length); g_info.download_status[tsv_name] = "saved"; + // Update size information after download + updateDownloadStatus(g_info, tsv_name); funAfter(chunk); }); }); @@ -1003,7 +1079,6 @@ var animint = function (to_select, json_file) { // update_geom is responsible for obtaining a chunk of downloaded // data, and then calling draw_geom to actually draw it. var draw_geom = function(g_info, chunk, selector_name, PANEL){ - g_info.tr.select("td.status").text("displayed"); var svg = SVGs[g_info.classed]; // derive the plot name from the geometry name var g_names = g_info.classed.split("_"); @@ -2381,14 +2456,16 @@ var animint = function (to_select, json_file) { } }); var loading = widget_td.append("table") - .style("display", "none"); + .attr("id", viz_id + "_download_status") + .style("display", "none") + .style("border-collapse", "collapse") + .style("border", "1px solid #ddd"); Widgets["loading"] = loading; var tr = loading.append("tr"); - tr.append("th").text("geom"); - tr.append("th").attr("class", "chunk").text("selected chunk"); - tr.append("th").attr("class", "downloaded").text("downloaded"); - tr.append("th").attr("class", "total").text("total"); - tr.append("th").attr("class", "status").text("status"); + applyCellStyles(tr.append("th").text("geom")); + applyCellStyles(tr.append("th").attr("class", "files").style("text-align", "right")).text("files"); + applyCellStyles(tr.append("th").attr("class", "disk").style("text-align", "right")).text("disk"); + applyCellStyles(tr.append("th").attr("class", "rows").style("text-align", "right")).text("rows"); // Add geoms and construct nest operators. for (var g_name in response.geoms) { diff --git a/tests/testthat/test-download-status-table.R b/tests/testthat/test-download-status-table.R new file mode 100644 index 000000000..b87bbaffa --- /dev/null +++ b/tests/testthat/test-download-status-table.R @@ -0,0 +1,42 @@ +acontext("download status table") +viz <- animint( + ggplot()+ + geom_point(aes(Sepal.Length, Sepal.Width), data=iris) +) +info <- animint2HTML(viz) +test_that("table has correct column headers", { + table_headers <- getNodeSet(info$html, '//table[contains(@id,"_download_status")]//th') + header_text <- sapply(table_headers, xmlValue) + expected <- c("geom", "files", "disk", "rows") + expect_true(all(expected %in% header_text)) +}) +test_that("numeric columns are right-justified", { + files_header <- getNodeSet(info$html, '//table[contains(@id,"_download_status")]//th[@class="files"]') + disk_header <- getNodeSet(info$html, '//table[contains(@id,"_download_status")]//th[@class="disk"]') + rows_header <- getNodeSet(info$html, '//table[contains(@id,"_download_status")]//th[@class="rows"]') + expect_equal(length(files_header), 1) + expect_equal(length(disk_header), 1) + expect_equal(length(rows_header), 1) + expect_match(xmlGetAttr(files_header[[1]], "style"), "text-align.*right") + expect_match(xmlGetAttr(disk_header[[1]], "style"), "text-align.*right") + expect_match(xmlGetAttr(rows_header[[1]], "style"), "text-align.*right") +}) +test_that("download status table displays correct content format", { + # Get all table cells from the download status table + table_cells <- getNodeSet(info$html, '//table[contains(@id,"_download_status")]//td') + cell_text <- sapply(table_cells, xmlValue) + # Should have cells for: geom name, files (1 / 1), disk (KiB/MiB), rows (with commas) + # Files column should show "downloaded / total" format + files_pattern <- "^[0-9]+ / [0-9]+$" + files_cells <- grep(files_pattern, cell_text, value = TRUE) + expect_true(length(files_cells) > 0, "Should have files column with 'downloaded / total' format") + # Disk column should show bytes with KiB or MiB units + disk_pattern <- "[0-9]+(\\.[0-9]+)? (KiB|MiB) / [0-9]+(\\.[0-9]+)? (KiB|MiB)" + disk_cells <- grep(disk_pattern, cell_text, value = TRUE) + expect_true(length(disk_cells) > 0, "Should have disk column with KiB/MiB units") + # Rows column should show numbers (may have commas for large numbers) + # Pattern allows digits with optional commas: 150 or 10,066 + rows_pattern <- "^[0-9,]+ / [0-9,]+$" + rows_cells <- grep(rows_pattern, cell_text, value = TRUE) + expect_true(length(rows_cells) > 0, "Should have rows column with numeric format") +}) diff --git a/tests/testthat/test-renderer1-variable-value.R b/tests/testthat/test-renderer1-variable-value.R index 13aa8480f..1da7fb01a 100644 --- a/tests/testthat/test-renderer1-variable-value.R +++ b/tests/testthat/test-renderer1-variable-value.R @@ -269,8 +269,10 @@ test_that("Widgets for regular selectors", { chunk.counts <- function(html=getHTML()){ node.set <- - getNodeSet(html, '//td[@class="downloaded"]') - as.integer(sapply(node.set, xmlValue)) + getNodeSet(html, '//td[@class="files"]') + text.vec <- sapply(node.set, xmlValue) + downloaded.vec <- sapply(strsplit(text.vec, " / "), "[[", 1) + as.integer(downloaded.vec) } test_that("counts of chunks downloaded or not at first", {