diff --git a/CLAUDE.md b/CLAUDE.md
index ab2887c9..37256249 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -251,6 +251,13 @@ Every path below exists; keep this list in step with `src/` when adding modules.
config table. Nothing else — its five other responsibilities were split out
(ADR-018), and it is imported by exactly one module, `DOMSetup.js`
- `spectrogramImage.js` - Spectrogram image load and scaling
+ - `imageSizing.js` - How big an image gram is drawn (issue #345), by the
+ optional `image-sizing` row: `screen` (the default: one image pixel per
+ screen pixel, shrunk to the room), `native` (one per CSS pixel, shrunk to
+ the room) or `legacy` (one per CSS pixel, capped at 1200). Measures the room
+ and refits on resize; the arithmetic is `utils/imageFit.js`. Under `screen`
+ and `native` every image gram gets the expand toggle, portrait snippets
+ included (`expandsAnyShape`)
- `svgLayout.js` - SVG layout, viewBox and zoom-transform application
- `src/rendering/` - Rendering system. These modules draw; they do not dispatch:
- `regionOverlay.js` - The region-zoom rubber band, the dashed outline of the
@@ -291,6 +298,7 @@ Every path below exists; keep this list in step with `src/` when adding modules.
under the mouse (the wheel), hold the middle of the view (the `+`/`−`
buttons). Pure, so "the gram under the pointer does not move" is pinned as a
property across levels rather than by one browser drag
+ - `imageFit.js` - The size an image-backed gram is drawn at, per sizing
- `doppler.js` - Doppler-specific calculations
- `harmonicSampling.js` - Pin sampling for dense harmonic sets
- `markerLabel.js` - Marker label normalisation and table abbreviation
diff --git a/debug-multiple.html b/debug-multiple.html
index 7628cc8a..89baf607 100644
--- a/debug-multiple.html
+++ b/debug-multiple.html
@@ -110,6 +110,11 @@
The problem: a 357 × 366 snippet cut from a 1677 × 380
+ gram is drawn larger than the image itself, axis labels and all.
+
The change: an optional config row, image-sizing, chooses how big a gram is
+ drawn, and its default is now screen:
+
+
legacy (before, now opt-in) — one image pixel per CSS pixel; anything
+ wider than 1200 CSS pixels is cut down to 1200, whatever room the page has.
+
screen (the default, after: the last two grams have no row at all) — one image pixel per screen pixel,
+ shrunk (aspect kept) only if it would not fit the width available. On a display scaled to 125% or
+ 150% a CSS pixel is 1.25 or 1.5 screen pixels, which is what makes legacy draw a small
+ image bigger than it is.
+
native — one image pixel per CSS pixel, with the same fit to the available width.
+ Identical to screen on an unscaled display.
+
+
Under screen and native every gram gets the expand button, the snippet included; under
+ legacy only landscape grams do.
+
Why before may look different here than on the target network: the legacy
+ large gram is 1200 CSS pixels wide. On a 1920-pixel screen scaled to 125% the window is about 1536 CSS
+ pixels, so 1200 nearly fills it; on a wider or unscaled screen it leaves a gap. Compare the scale factor
+ below with the target network's.
+
This display: … screen pixels per CSS pixel, window
+ … CSS pixels wide.
+ Under each gram is the size it is drawn at, in CSS pixels.
+
+
+
Large gram, 1677 × 380 before — legacy
+
+
+
+
time-start
0
+
time-end
60
+
freq-start
0
+
freq-end
500
+
image-sizing
legacy
+
+
+
+
Snippet, 357 × 366 before — legacy
+
+
+
+
time-start
0
+
time-end
58
+
freq-start
185
+
freq-end
291
+
image-sizing
legacy
+
+
+
+
Large gram, 1677 × 380 after — default
+
+
+
+
time-start
0
+
time-end
60
+
freq-start
0
+
freq-end
500
+
+
+
+
Snippet, 357 × 366 after — default
+
+
+
+
time-start
0
+
time-end
58
+
freq-start
185
+
freq-end
291
+
+
+
+
+
+
diff --git a/docs/HTML-Integration-Guide.md b/docs/HTML-Integration-Guide.md
index d3100d63..9e06c349 100644
--- a/docs/HTML-Integration-Guide.md
+++ b/docs/HTML-Integration-Guide.md
@@ -52,6 +52,7 @@ The configuration table uses a 2-column format: `parameter | value`.
| `time-end` | number | Yes | End time value (top of Y-axis). Must be > `time-start` |
| `freq-start` | number | Yes | Start frequency value (left of X-axis) |
| `freq-end` | number | Yes | End frequency value (right of X-axis). Must be > `freq-start` |
+| `image-sizing` | `screen` / `native` / `legacy` | No (default `screen`) | How big the image is drawn. `screen`: one image pixel per screen pixel, so a display scaled to 125% or 150% does not enlarge it. `native`: one image pixel per CSS pixel. Both shrink the image, keeping its shape, only when it would not fit the page's width, and give every gram the expand button. `legacy`: one image pixel per CSS pixel, capped at 1200 wide whatever the room, with the expand button on landscape grams only |
The first row must contain an `` element with the spectrogram image (using `colspan="2"`).
@@ -309,7 +310,8 @@ State is deep-copied before being passed to listeners, so you cannot accidentall
## Expand API
-A landscape gram can be expanded to fill the space around it. The toggle is a
+A gram can be expanded to fill the space around it — any gram under the
+default `image-sizing`, landscape grams only under `legacy`. The toggle is a
button on the component; these two methods drive the same state from a host
page:
@@ -317,7 +319,7 @@ page:
// Is the first instance currently expanded?
const expanded = GramFrame.getExpandState()
-// Expand (or collapse) every landscape instance on the page
+// Expand (or collapse) every instance on the page that has the toggle
GramFrame.setExpandState(true)
```
diff --git a/index.html b/index.html
index 8b300262..15cb70e0 100644
--- a/index.html
+++ b/index.html
@@ -112,6 +112,11 @@
Spectrograph player audio
Player trial harness pick a file
The package sent to stakeholders: choose one of the four samples, or any WAV on your own machine, then tune the analysis and the painting against it — FFT size, overlap, frame averaging, background normalisation and the display percentiles. Nothing is uploaded.
diff --git a/sample/issue-345/gram-large.png b/sample/issue-345/gram-large.png
new file mode 100644
index 00000000..585a0b73
Binary files /dev/null and b/sample/issue-345/gram-large.png differ
diff --git a/sample/issue-345/gram-snippet.png b/sample/issue-345/gram-snippet.png
new file mode 100644
index 00000000..5cd8feb0
Binary files /dev/null and b/sample/issue-345/gram-snippet.png differ
diff --git a/sample/pub10-vernier.html b/sample/pub10-vernier.html
index 38a84bff..2c05295e 100644
--- a/sample/pub10-vernier.html
+++ b/sample/pub10-vernier.html
@@ -3,7 +3,7 @@
- Pub-10 — Vernier (portrait, no expand toggle)
+ Pub-10 — Vernier (portrait)
@@ -40,7 +40,8 @@
Vernier — Portrait Gram
-
+
diff --git a/src/api/GramFrameAPI.js b/src/api/GramFrameAPI.js
index a8fe8026..4cf7ca4c 100644
--- a/src/api/GramFrameAPI.js
+++ b/src/api/GramFrameAPI.js
@@ -16,7 +16,7 @@ import {
dispatch,
flushDispatch
} from '../core/state.js'
-import { setImageExpanded, isLandscape } from '../components/ExpandToggle.js'
+import { setImageExpanded, canExpand } from '../components/ExpandToggle.js'
import { isBrowserSupported, showCompatibilityWarning, looksLikeMissingApiError } from '../core/browserCompatibility.js'
import { createErrorIndicator } from '../components/ErrorIndicator.js'
@@ -332,13 +332,13 @@ export function createGramFrameAPI(GramFrame) {
},
/**
- * Programmatically expand or collapse all landscape GramFrame instances.
- * No-op for portrait/square images (mirrors the toggle's landscape gate).
+ * Programmatically expand or collapse every GramFrame instance that has the
+ * expand toggle; a no-op for those that do not (mirrors the toggle's gate).
* @param {boolean} expanded - Desired expand state
*/
setExpandState(expanded) {
this._getInstances().forEach(instance => {
- if (isLandscape(instance)) {
+ if (canExpand(instance)) {
setImageExpanded(instance, expanded)
}
})
diff --git a/src/components/ExpandToggle.js b/src/components/ExpandToggle.js
index e45d40c3..8da5bc45 100644
--- a/src/components/ExpandToggle.js
+++ b/src/components/ExpandToggle.js
@@ -2,8 +2,10 @@
* Expand/collapse toggle control for the spectrogram image.
*
* A small floating button at the top-left of the image region that expands a
- * landscape gram to fill the available space and restores it. Portrait/square
- * images (verniers) receive no toggle. Expand state is in-memory only.
+ * landscape gram to fill the available space and restores it. Under legacy
+ * sizing, portrait/square images (verniers) receive no toggle; the
+ * default `screen` sizing gives every shape one (issue #345). Expand state is
+ * in-memory only.
*
* An audio-sourced gram (spec 168) always gets the toggle: its natural size is
* bins × frames — portrait, and thousands of rows tall — but it is drawn at a
@@ -17,6 +19,7 @@ import { updateSVGLayout } from './svgLayout.js'
import { renderAxes } from '../rendering/axes.js'
import { dispatch } from '../core/state.js'
import { baseRenderSize, isPlayerActive } from '../player/playerView.js'
+import { expandsAnyShape } from './imageSizing.js'
// Small gap left between the expanded image and the viewport bottom (px).
const BOTTOM_GAP = 16
@@ -27,11 +30,22 @@ const BOTTOM_GAP = 16
* @param {GramFrame} instance - GramFrame instance
* @returns {boolean} True if the image is landscape
*/
-export function isLandscape(instance) {
+function isLandscape(instance) {
const { width, height } = baseRenderSize(instance)
return width > 0 && height > 0 && width > height
}
+/**
+ * Whether the instance gets the expand toggle: a landscape gram always does;
+ * under the `native` and `screen` sizings (issue #345) every image gram does, portrait
+ * snippets included, and expands the way a landscape one does.
+ * @param {GramFrame} instance - GramFrame instance
+ * @returns {boolean} True if the image can be expanded
+ */
+export function canExpand(instance) {
+ return isLandscape(instance) || expandsAnyShape(instance)
+}
+
/**
* Compute the render dimensions that fill the available space.
*
@@ -135,12 +149,12 @@ function updateToggleButton(button, expanded) {
}
/**
- * Programmatically set the expand state (no-op for non-landscape images).
+ * Programmatically set the expand state (no-op where `canExpand` says no).
* @param {GramFrame} instance - GramFrame instance
* @param {boolean} expanded - Desired expand state
*/
export function setImageExpanded(instance, expanded) {
- if (!isLandscape(instance)) {
+ if (!canExpand(instance)) {
return
}
instance.state.imageExpanded = !!expanded
@@ -157,23 +171,23 @@ export function setImageExpanded(instance, expanded) {
* @param {GramFrame} instance - GramFrame instance
*/
export function refreshExpandedLayout(instance) {
- if (!instance.state.imageExpanded) {
+ const { imageExpanded, imageDetails } = instance.state
+ if (!imageExpanded) {
return
}
const { width, height } = computeAvailableRenderSize(instance)
- const imageDetails = instance.state.imageDetails
imageDetails.renderWidth = width
imageDetails.renderHeight = height
}
/**
- * Create and mount the expand toggle for a landscape instance. Does nothing for
- * portrait/square images, so no toggle exists in the DOM for verniers.
+ * Create and mount the expand toggle where `canExpand` allows it. Under legacy
+ * sizing that is landscape grams only, so no toggle exists in the DOM for verniers.
* @param {GramFrame} instance - GramFrame instance
- * @returns {HTMLButtonElement|null} The created button, or null if not landscape
+ * @returns {HTMLButtonElement|null} The created button, or null if it cannot expand
*/
export function createExpandToggle(instance) {
- if (!isLandscape(instance)) {
+ if (!canExpand(instance)) {
return null
}
diff --git a/src/components/imageSizing.js b/src/components/imageSizing.js
new file mode 100644
index 00000000..9e3b0e97
--- /dev/null
+++ b/src/components/imageSizing.js
@@ -0,0 +1,142 @@
+/**
+ * How big an image-backed gram is drawn (issue #345).
+ *
+ * `screen` is the default; the `image-sizing` config row can choose another
+ * per gram, `legacy` being the way back to what every gram did before.
+ *
+ * - `legacy`: one image pixel per CSS pixel, with anything wider than 1200 CSS
+ * pixels scaled down to 1200 — whatever room the page actually has.
+ * - `native`: one image pixel per CSS pixel, scaled down (aspect kept) only
+ * when that would not fit the width the component has.
+ * - `screen` (the default): one image pixel per *screen* pixel — the CSS size divided by
+ * `devicePixelRatio` — under the same fit. On a display scaled to 150% a CSS
+ * pixel is 1.5 screen pixels, so `native` draws a small snippet half as big
+ * again as the image itself, its axis labels with it; `screen` does not.
+ *
+ * The chosen size becomes the gram's base size (`naturalWidth/Height`), exactly
+ * as the legacy down-scale always did, so zoom, expand and every coordinate
+ * transform are unchanged. The two fitting strategies refit on resize; legacy
+ * never did and still does not.
+ */
+
+///
+
+import { fitImageSize } from '../utils/imageFit.js'
+
+/** The values the `image-sizing` row accepts. */
+export const IMAGE_SIZINGS = ['legacy', 'native', 'screen']
+
+/** The sizing of a gram whose table has no `image-sizing` row. */
+export const DEFAULT_IMAGE_SIZING = 'screen'
+
+/**
+ * @typedef {Object} SizingRecord
+ * @property {string} sizing - One of IMAGE_SIZINGS
+ * @property {number} sourceWidth - The image file's own width, 0 until loaded
+ * @property {number} sourceHeight - The image file's own height
+ */
+
+/** @type {WeakMap