diff --git a/CHANGELOG.md b/CHANGELOG.md
index 833a4ef6..d04f98c8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,15 @@
#### ๐ Enhancement
+- Added three "scaled" usage metrics to the per-Dandiset table, normalizing raw usage by how much content each Dandiset actually holds: "Views / Asset", "Downloads / Asset" and "Bytes / Size" (bytes sent per byte stored). Their denominators come from two new data sources, [dandi-cache/dandiset-id-to-number-of-assets](https://github.com/dandi-cache/dandiset-id-to-number-of-assets) and [dandi-cache/dandiset-id-to-total-size](https://github.com/dandi-cache/dandiset-id-to-total-size), which are gzipped JSONL files fetched from the GitHub raw CDN and decompressed in the browser. A failure to load either one leaves the rest of the page working. ([#241](https://github.com/dandi/usage-page/pull/241))
+- Added "Total Size" and "Total Assets" columns to the end of the per-Dandiset table, so the denominators behind the scaled metrics are visible alongside them. Both show "--" for Dandisets missing from the content derivatives. ([#241](https://github.com/dandi/usage-page/pull/241))
+- Added an "Ignore testing datasets" checkbox to the per-Dandiset section's gear-wheel settings panel, which leaves out the Dandisets listed in `TESTING_DANDISET_IDS` โ those whose usage is dominated by automated testing of the archive, and which otherwise dominate the scaled metrics. It is on by default, so a first visit shows research usage rather than testing traffic; turning it off is remembered in the URL (`ignore_testing=false`). It applies to the table (including its download) rather than the plot, and is shown only for the archive-wide selection, the only one whose rows are Dandisets. ([#241](https://github.com/dandi/usage-page/pull/241))
+- Stopped disabling the per-Dandiset gear-wheel settings button in table view, since its panel now holds a table setting as well as the plot-only "Plot Type"; both entries carry an info icon naming the view they apply to, and the panel is titled "Settings" rather than "Plot Settings". ([#241](https://github.com/dandi/usage-page/pull/241))
+- Kept the sort a user has chosen for a table when that table is re-rendered with new rows โ toggling "Ignore testing datasets" or the binary/decimal prefix no longer snaps the table back to its default sort. The remembered sort is tied to the table's container and column set, so a container that goes on to show a different table (the per-asset table replacing the per-Dandiset one) still starts from that table's own default. ([#241](https://github.com/dandi/usage-page/pull/241))
+- Replaced the "Download raw file" item of every table's "Data โพ" menu with "Download table", which hands back a TSV of the table as displayed โ the same columns, the same formatting, and the sort order currently in effect โ rather than the source file, which no longer contains the derived columns. The source file itself is still one click away under "View file on GitHub". ([#241](https://github.com/dandi/usage-page/pull/241))
+- Ordered the per-Dandiset table so each scaled metric sits beside what it is divided by: the per-asset rates with "Total Assets", then the usage totals, closing on "Bytes / Size" and the directly comparable "Total Bytes" and "Total Size" pair. Renamed the raw totals to "Total Bytes", "Total Views" and "Total Downloads", dropped the request columns (requests remain in the plot's hover text and in every other table), and let that table use the full page width (instead of the 1100px the other tables are capped at) so all of its columns are visible at once on a wide screen. Table columns gained an optional `default_sort` flag, used to keep "Total Bytes" the column the table is sorted by on load now that it is no longer the leftmost metric. ([#241](https://github.com/dandi/usage-page/pull/241))
+- Rendered a scaled metric as "--" when its denominator is unknown (a Dandiset missing from the content derivatives, such as 'undetermined') or zero, rather than as a blank, zero, or infinite value. Table rows without a value for the sorted column now always sink to the bottom, in both sort directions. ([#241](https://github.com/dandi/usage-page/pull/241))
+
- Surfaced the view counts now published by [dandi/access-summaries](https://github.com/dandi/access-summaries) (`number_of_views` in `by_day.tsv`, `by_region.tsv` and `by_asset.tsv`; `total_number_of_views` in `totals.json` and `archive_totals.json`) throughout the page: a new "Views" column in every sortable table (over-time, per-Dandiset histogram, per-asset histogram, per-region, AWS regions), a `Views` entry in the hover text of every plot (over-time single-series and both grouped modes, per-Dandiset and per-asset histograms, geographic scatter and choropleth), and a view count in the totals sentence above the plots with an explanatory footnote. ([#240](https://github.com/dandi/usage-page/pull/240))
- Renamed the "Usage" column of every table view to "Bytes". ([#240](https://github.com/dandi/usage-page/pull/240))
- Ordered the metric columns of every table view as "Views", "Downloads", "Requests" (after the leading "Bytes" column). ([#240](https://github.com/dandi/usage-page/pull/240))
@@ -21,12 +30,16 @@
#### ๐ Internal
+- Renamed `AGENTS.md` to `CLAUDE.md` and recorded the American-English spelling convention in it. ([#241](https://github.com/dandi/usage-page/pull/241))
+
- Updated the version-check CI workflow to not trigger on `package-lock.json` changes; updated `AGENTS.md` to match. ([#174](https://github.com/dandi/usage-page/pull/174))
- Updated the version-check workflow to allow dependency-only `package.json` changes (for example Dependabot dependency bumps) without requiring a package version bump. ([#189](https://github.com/dandi/usage-page/pull/189))
- Fixed version-check CI parsing in the dependency-only `package.json` branch by replacing a malformed heredoc with `node -e`, so Dependabot dependency bumps no longer fail unexpectedly. ([#181](https://github.com/dandi/usage-page/pull/181))
#### ๐งช Tests
+- Added unit tests for the new gzipped-JSONL data path (`parse_dandiset_numbers_jsonl`, `decode_maybe_gzipped_response`, `fetch_maybe_gzipped_text`), the `scaled_metric` and `format_ratio` helpers, and the table renderer's handling of missing numeric values. The Chromatic fixtures now serve gzipped asset-count and total-size files, so the snapshot run exercises the client-side decompression too. ([#241](https://github.com/dandi/usage-page/pull/241))
+
- Extracted `escape_html`, `make_cumulative`, `fetchWithRetry`, `apply_view_mode`, `apply_geo_view_mode`, and `render_sortable_table` into a new `src/plot-helpers.ts` module and added 50 unit tests covering all six helpers; raised overall statement coverage from 5% to 13%. ([#175](https://github.com/dandi/usage-page/pull/175))
- Added `stories/PlotSections.stories.js` with Storybook stories for the over-time plot, histogram, geography, and sortable-table components in both dark and light themes. ([#175](https://github.com/dandi/usage-page/pull/175))
- Replaced the live version string in the footer with a fixed mock value (`v0.0.0+test0000`) before taking Chromatic Playwright snapshots so the baseline is not invalidated by version bumps or new commits. ([#178](https://github.com/dandi/usage-page/pull/178))
diff --git a/AGENTS.md b/CLAUDE.md
similarity index 74%
rename from AGENTS.md
rename to CLAUDE.md
index c18ad89b..0c21afab 100644
--- a/AGENTS.md
+++ b/CLAUDE.md
@@ -4,3 +4,4 @@
- Always bump the version in `package.json` appropriately when any file under `src/`, or `package.json` itself, is changed
- Leave a short description of the change or addition in the top `# Upcoming` section of the `CHANGELOG.md`; include the GitHub PR link at the end of each entry in the format `([#N](https://github.com/dandi/usage-page/pull/N))`
- PR titles should be human-readable and in the past tense; they should NOT use conventional commit style
+- Use American English spelling everywhere (code, comments, UI text, changelog entries, and PR descriptions): "normalize" not "normalise", "color" not "colour", "behavior" not "behaviour"
diff --git a/package.json b/package.json
index 5f5a47f6..e9adb4bc 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "access-page",
- "version": "1.8.0",
+ "version": "1.9.0",
"description": "Visualizations of data usage across the archive.",
"private": true,
"type": "module",
diff --git a/src/index.html b/src/index.html
index cb9510db..b04963a0 100644
--- a/src/index.html
+++ b/src/index.html
@@ -248,14 +248,20 @@
-
Plot Settings
+
Settings
+ i
+
+
+ i
+
+
diff --git a/src/plot-helpers.ts b/src/plot-helpers.ts
index 1828b715..e5425cc3 100644
--- a/src/plot-helpers.ts
+++ b/src/plot-helpers.ts
@@ -84,28 +84,107 @@ export async function fetchWithRetry(url: string, options: RequestInit = {}, max
throw new Error("fetchWithRetry: exhausted retries");
}
-// โโ Dandiset ID โ title mapping โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+// โโ Gzip decoding โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
/**
- * Parses a newline-delimited JSON (JSONL) file where each line is a single
- * `{ "": "" }` object, merging them into one lookup map.
- * Blank lines and lines that fail to parse are skipped so a single malformed
- * entry doesn't prevent the rest of the file from loading.
+ * Decodes a fetched response body that may be gzip-compressed into text.
+ *
+ * The gzipped derivative files are served as raw `application/octet-stream`
+ * bytes (no `Content-Encoding: gzip` header), so the browser hands them over
+ * still compressed and they have to be inflated here. A caching layer that
+ * *does* advertise the encoding will have transparently decompressed the body
+ * already, so the gzip magic number is checked first and an already-plain body
+ * is simply decoded as text.
*/
-export function parse_dandiset_titles_jsonl(text: string): Record {
- const titles: Record = {};
+export async function decode_maybe_gzipped_response(response: Response): Promise {
+ const bytes = new Uint8Array(await response.arrayBuffer());
+ const is_gzipped = bytes.length > 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
+ if (!is_gzipped) return new TextDecoder().decode(bytes);
+ if (typeof DecompressionStream === "undefined") {
+ throw new Error("Gzipped data cannot be decoded: DecompressionStream is unavailable.");
+ }
+
+ const stream = new DecompressionStream("gzip");
+ const writer = stream.writable.getWriter();
+ // Feeding the stream is deliberately not awaited here: the writes only
+ // settle once the reader below drains the decompressed output, so awaiting
+ // them first would deadlock. A corrupt body rejects on both ends of the
+ // stream, and it is the reader's rejection that is propagated, so the
+ // writer's duplicate is swallowed rather than left unhandled.
+ writer
+ .write(bytes)
+ .then(() => writer.close())
+ .catch(() => {});
+
+ const reader = stream.readable.getReader();
+ const decoder = new TextDecoder();
+ let text = "";
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ text += decoder.decode(value, { stream: true });
+ }
+ return text + decoder.decode();
+}
+
+/**
+ * Fetches a (possibly gzipped) file and returns its decoded text, retrying
+ * transient failures via `fetchWithRetry`.
+ */
+export async function fetch_maybe_gzipped_text(url: string): Promise {
+ return decode_maybe_gzipped_response(await fetchWithRetry(url));
+}
+
+// โโ Dandiset ID โ value mappings โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+/**
+ * Walks a newline-delimited JSON (JSONL) file where each line is a single
+ * `{ "": }` object, invoking `visit` for every key/value
+ * pair. Blank lines and lines that fail to parse are skipped so a single
+ * malformed entry doesn't prevent the rest of the file from loading.
+ */
+function for_each_jsonl_entry(text: string, visit: (key: string, value: unknown) => void): void {
text.split("\n").forEach((line) => {
const trimmed = line.trim();
if (!trimmed) return;
+ let parsed: unknown;
try {
- Object.assign(titles, JSON.parse(trimmed));
+ parsed = JSON.parse(trimmed);
} catch {
// Skip malformed lines rather than failing the whole page.
+ return;
}
+ if (parsed === null || typeof parsed !== "object") return;
+ Object.entries(parsed as Record).forEach(([key, value]) => visit(key, value));
+ });
+}
+
+/**
+ * Parses a JSONL file of `{ "": "" }` lines into one
+ * lookup map of Dandiset ID โ title.
+ */
+export function parse_dandiset_titles_jsonl(text: string): Record {
+ const titles: Record = {};
+ for_each_jsonl_entry(text, (key, value) => {
+ titles[key] = value as string;
});
return titles;
}
+/**
+ * Parses a JSONL file of `{ "": }` lines (the asset-count
+ * and total-size derivatives) into one lookup map of Dandiset ID โ number.
+ * Entries whose value is not a finite number are dropped, so a malformed value
+ * shows up as missing data rather than as a bogus metric.
+ */
+export function parse_dandiset_numbers_jsonl(text: string): Record {
+ const numbers: Record = {};
+ for_each_jsonl_entry(text, (key, value) => {
+ if (typeof value === "number" && isFinite(value)) numbers[key] = value;
+ });
+ return numbers;
+}
+
/**
* Formats a Dandiset ID for display, appending " - " when a title is
* known. The "archive" sentinel ID (the whole-archive selection) is always
@@ -215,8 +294,81 @@ export function derive_data_source_urls(raw_url: string): { raw: string; file: s
};
}
+// โโ Table download โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+
+interface TableColumn {
+ label: string;
+ key: string;
+ numeric: boolean;
+ format_fn?: (val: number) => string;
+ link_fn?: (row: Record) => string | null;
+ default_sort?: boolean;
+}
+
+/**
+ * Returns the text a table cell displays for one column of one row: numeric
+ * columns go through the column's formatter (or the table's default one),
+ * everything else is stringified as-is.
+ */
+function table_cell_text(col: TableColumn, row: Record, format_fn: (val: number) => string): string {
+ return col.numeric ? (col.format_fn ?? format_fn)(row[col.key] as number) : String(row[col.key] ?? "");
+}
+
+/**
+ * Serializes a table to tab-separated values exactly as it is displayed: the
+ * same columns in the same order, the same formatted cell text, and `rows` in
+ * whatever order they are passed (the caller passes the current sort order).
+ * This is what the "Download table" menu item hands back, since the on-page
+ * table carries derived columns that no single source file contains.
+ *
+ * Tabs and newlines inside a cell are collapsed to spaces so one stray value
+ * cannot break the column alignment of the whole file.
+ */
+export function build_table_tsv(
+ columns: TableColumn[],
+ rows: Array>,
+ format_fn: (bytes: number) => string = format_bytes_default
+): string {
+ const sanitize = (text: string) => text.replace(/[\t\r\n]+/g, " ");
+ const lines = [columns.map((col) => sanitize(col.label)).join("\t")];
+ rows.forEach((row) => {
+ lines.push(columns.map((col) => sanitize(table_cell_text(col, row, format_fn))).join("\t"));
+ });
+ return lines.join("\n") + "\n";
+}
+
+/**
+ * Derives a download filename from a table's heading, e.g. "Usage per
+ * Dandiset" โ "usage_per_dandiset.tsv".
+ */
+export function table_download_filename(title: string): string {
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
+ return `${slug || "table"}.tsv`;
+}
+
+/**
+ * Hands `text` to the browser as a file download, via a temporary object URL.
+ */
+function download_text_file(text: string, filename: string, mime_type: string): void {
+ const url = URL.createObjectURL(new Blob([text], { type: mime_type }));
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ URL.revokeObjectURL(url);
+}
+
// โโ Sortable table renderer โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+/**
+ * The sort each table is currently under, so that re-rendering it with new
+ * rows can restore it. Keyed by the container element (rather than its ID) so
+ * the state disappears with the element it belongs to.
+ */
+const TABLE_SORT_STATE = new WeakMap();
+
/**
* Renders a sortable HTML table inside a container element.
* Clicking a column header re-sorts the table in place and updates the sort
@@ -227,19 +379,22 @@ export function derive_data_source_urls(raw_url: string): { raw: string; file: s
* @param columns - Column definitions. `numeric: true` formats the cell value with
* `format_fn()`; otherwise the raw value is displayed as-is. An optional
* `link_fn(row)` turns a non-numeric cell into a hyperlink: it returns the
- * target URL, or `null` for rows that should stay plain text.
+ * target URL, or `null` for rows that should stay plain text. An optional
+ * `default_sort: true` marks the column the table is sorted by on first
+ * render, for tables whose primary metric is not their leftmost one.
* @param rows - Data rows (plain objects keyed by column.key).
* @param format_fn - Formatter applied to numeric cell values. Defaults to
* `format_bytes` (decimal SI suffixes).
* @param data_url - Optional URL to the source data file; when provided a
- * "Data โพ" menu (GitHub file view / raw download / containing folder)
- * is rendered top-right in the table header. Falls back to a plain
- * "Data" hyperlink when the URL is not a raw.githubusercontent.com URL.
+ * "Data โพ" menu (GitHub file view / download of the table as shown /
+ * containing folder) is rendered top-right in the table header. Falls
+ * back to a plain "Data" hyperlink when the URL is not a
+ * raw.githubusercontent.com URL.
*/
export function render_sortable_table(
container_id: string,
title: string,
- columns: Array<{label: string; key: string; numeric: boolean; format_fn?: (val: number) => string; link_fn?: (row: Record) => string | null}>,
+ columns: TableColumn[],
rows: Array>,
format_fn: (bytes: number) => string = format_bytes_default,
data_url?: string
@@ -247,14 +402,29 @@ export function render_sortable_table(
const container = document.getElementById(container_id);
if (!container) return;
- // Default: sort by the first numeric column (the primary "Bytes" metric in
- // every table here) descending, falling back to the last column when no
- // column is numeric. Anchoring to the first numeric column rather than the
- // last keeps the default ordering stable as further metric columns are
- // appended.
+ // Default: sort by the column flagged `default_sort`, else by the first
+ // numeric column (the primary "Bytes" metric in most tables here),
+ // falling back to the last column when no column is numeric. Anchoring to
+ // a named or leading column rather than the last keeps the default ordering
+ // stable as further metric columns are added.
// sort_asc: true = ascending (AโZ / lowโhigh), false = descending (ZโA / highโlow)
- let sort_key = (columns.find((col) => col.numeric) ?? columns[columns.length - 1]).key;
- let sort_asc = false; // start descending so highest values appear first
+ const default_sort_key = (
+ columns.find((col) => col.default_sort) ??
+ columns.find((col) => col.numeric) ??
+ columns[columns.length - 1]
+ ).key;
+
+ // Re-rendering a table with fresh rows (a settings toggle, a reload of the
+ // same view) keeps whatever sort the user last chose for it, instead of
+ // snapping back to the default. The remembered state only applies to a
+ // table with the same columns, so a container that later shows a different
+ // table (the per-asset table replacing the per-Dandiset one, say) starts
+ // from that table's own default.
+ const signature = columns.map((col) => col.key).join(" ");
+ const remembered = TABLE_SORT_STATE.get(container);
+ const restored = remembered?.signature === signature ? remembered : null;
+ let sort_key = restored?.key ?? default_sort_key;
+ let sort_asc = restored?.asc ?? false; // start descending so highest values appear first
function render_table() {
const sorted = [...rows].sort((a, b) => {
@@ -262,7 +432,16 @@ export function render_sortable_table(
const vb = b[sort_key];
const factor = sort_asc ? 1 : -1;
if (typeof va === "number" && typeof vb === "number") {
- return factor * ((va as number) - (vb as number));
+ // Rows without a value for this column (NaN, e.g. a scaled
+ // metric whose denominator is unknown) always sink to the
+ // bottom, in both sort directions.
+ const va_missing = !isFinite(va);
+ const vb_missing = !isFinite(vb);
+ if (va_missing || vb_missing) {
+ if (va_missing && vb_missing) return 0;
+ return va_missing ? 1 : -1;
+ }
+ return factor * (va - vb);
}
// Numeric-aware locale comparison handles Dandiset IDs like "000123"
return factor * String(va).localeCompare(String(vb), undefined, { numeric: true });
@@ -270,13 +449,13 @@ export function render_sortable_table(
let data_link = "";
if (data_url) {
- const { raw, file, folder } = derive_data_source_urls(data_url);
+ const { file, folder } = derive_data_source_urls(data_url);
data_link = file && folder
? `