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 @@

GramFrame Instance 2a

freq-end 5000 + + + image-sizing + legacy + diff --git a/demo/image-sizing.html b/demo/image-sizing.html new file mode 100644 index 00000000..d0f94f4f --- /dev/null +++ b/demo/image-sizing.html @@ -0,0 +1,121 @@ + + + + + + GramFrame Image Sizing + + + + +

Image Sizing (issue #345)

+ + + +
+

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:

+ +

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

+
+ + + + + + + +
Large spectrogram
time-start0
time-end60
freq-start0
freq-end500
image-sizinglegacy
+
+ +

Snippet, 357 × 366 before — legacy

+
+ + + + + + + +
Snippet spectrogram
time-start0
time-end58
freq-start185
freq-end291
image-sizinglegacy
+
+ +

Large gram, 1677 × 380 after — default

+
+ + + + + + +
Large spectrogram
time-start0
time-end60
freq-start0
freq-end500
+
+ +

Snippet, 357 × 366 after — default

+
+ + + + + + +
Snippet spectrogram
time-start0
time-end58
freq-start185
freq-end291
+
+ + + + 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.

+ + +

Image sizing before / after

+

A wide gram and a small snippet cut from it, drawn the old way (image-sizing: legacy) and the new default: one image pixel per screen pixel, shrunk only to fit the page (issue #345).

+

Developer pages

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} */ +const records = new WeakMap() + +/** + * Record the sizing a gram's config table asked for. + * @param {GramFrame} instance - GramFrame instance + * @param {string} sizing - One of IMAGE_SIZINGS + */ +export function setImageSizing(instance, sizing) { + records.set(instance, { sizing, sourceWidth: 0, sourceHeight: 0 }) +} + +/** + * Whether this gram's sizing gives every shape the expand toggle. The fitting + * sizings do, so a portrait snippet can be expanded too (issue #345); legacy + * keeps the toggle for landscape grams only. + * @param {GramFrame} instance - GramFrame instance + * @returns {boolean} True under `native` or `screen` + */ +export function expandsAnyShape(instance) { + const record = records.get(instance) + return (record ? record.sizing : DEFAULT_IMAGE_SIZING) !== 'legacy' +} + +/** + * The width the image area can take without overflowing the component: the + * main panel's content width less the SVG border and the axis margins. The SVG + * is taken out of the flow while measuring, so its current size cannot feed + * back into the width it is about to be given. + * @param {GramFrame} instance - GramFrame instance + * @param {{left: number, right: number}} margins - The axis margins + * @returns {number} Available width in CSS pixels, 0 when it cannot be measured + */ +function measureAvailableWidth(instance, margins) { + const { mainCell, svg } = instance.ui + if (!mainCell || !svg) return 0 + const cellStyle = window.getComputedStyle(mainCell) + const svgStyle = window.getComputedStyle(svg) + const previousDisplay = svg.style.display + svg.style.display = 'none' + const inner = mainCell.clientWidth - + (parseFloat(cellStyle.paddingLeft) || 0) - (parseFloat(cellStyle.paddingRight) || 0) + svg.style.display = previousDisplay + const border = (parseFloat(svgStyle.borderLeftWidth) || 0) + (parseFloat(svgStyle.borderRightWidth) || 0) + return Math.max(0, Math.floor(inner - border - margins.left - margins.right)) +} + +/** + * Work out the base size for this gram's image and write it into the image + * details, as the natural size and (unless expanded) the render size. + * @param {GramFrame} instance - GramFrame instance + * @param {GramFrameState} viewport - The instance's state + * @returns {boolean} Whether the base size changed + */ +function applySize(instance, viewport) { + const record = records.get(instance) + if (!record || !record.sourceWidth) return false + const size = fitImageSize( + { width: record.sourceWidth, height: record.sourceHeight }, + record.sizing, + { availableWidth: measureAvailableWidth(instance, viewport.margins), pixelRatio: window.devicePixelRatio || 1 } + ) + const details = viewport.imageDetails + if (size.width === details.naturalWidth && size.height === details.naturalHeight) return false + details.naturalWidth = size.width + details.naturalHeight = size.height + if (!viewport.imageExpanded) { + details.renderWidth = size.width + details.renderHeight = size.height + } + return true +} + +/** + * Size a freshly loaded image. Sets the natural and render size. + * @param {GramFrame} instance - GramFrame instance + * @param {GramFrameState} viewport - The instance's state + * @param {{width: number, height: number}} source - The image file's pixel size + */ +export function sizeLoadedImage(instance, viewport, source) { + const record = records.get(instance) || { sizing: DEFAULT_IMAGE_SIZING, sourceWidth: 0, sourceHeight: 0 } + record.sourceWidth = source.width + record.sourceHeight = source.height + records.set(instance, record) + applySize(instance, viewport) + const { naturalWidth, naturalHeight } = viewport.imageDetails + if (naturalWidth !== source.width) { + const verb = naturalWidth < source.width ? 'Scaling down large image' : 'Scaling image' + const sizing = record.sizing === 'legacy' ? '' : `, image-sizing: ${record.sizing}` + console.log(`GramFrame: ${verb} from ${source.width}x${source.height} to ${naturalWidth}x${naturalHeight} (scale factor: ${(naturalWidth / source.width).toFixed(3)}${sizing})`) + } +} + +/** + * Refit after the component is resized. A no-op under `legacy`, which never + * followed the page's width. + * @param {GramFrame} instance - GramFrame instance + * @param {GramFrameState} viewport - The instance's state + */ +export function refitImage(instance, viewport) { + const record = records.get(instance) + if (!record || record.sizing === 'legacy') return + applySize(instance, viewport) +} diff --git a/src/components/spectrogramImage.js b/src/components/spectrogramImage.js index febecad6..a7dc29d6 100644 --- a/src/components/spectrogramImage.js +++ b/src/components/spectrogramImage.js @@ -13,9 +13,8 @@ import { dispatch } from '../core/state.js' import { renderAxes } from '../rendering/axes.js' import { createExpandToggle } from './ExpandToggle.js' import { updateSVGLayout } from './svgLayout.js' +import { sizeLoadedImage } from './imageSizing.js' -// Maximum image width in pixels - images wider than this will be scaled down -const MAX_IMAGE_WIDTH = 1200 /** * Set up spectrogram image display within SVG container @@ -39,28 +38,10 @@ export function setupSpectrogramImage(instance, imageUrl) { // Dimensions are known, so the panel is about to render for real instance.ui.container.classList.remove('gram-frame-loading') - // Get original dimensions - let imageWidth = tempImg.naturalWidth - let imageHeight = tempImg.naturalHeight - - // Apply automatic scaling for images wider than the maximum allowed width - if (imageWidth > MAX_IMAGE_WIDTH) { - const scaleFactor = MAX_IMAGE_WIDTH / imageWidth - imageWidth = MAX_IMAGE_WIDTH - imageHeight = Math.round(imageHeight * scaleFactor) - - console.log(`GramFrame: Scaling down large image from ${tempImg.naturalWidth}x${tempImg.naturalHeight} to ${imageWidth}x${imageHeight} (scale factor: ${scaleFactor.toFixed(3)})`) - } - - // Store scaled dimensions as natural dimensions - const imageDetails = instance.state.imageDetails - imageDetails.naturalWidth = imageWidth - imageDetails.naturalHeight = imageHeight - - // Initialise render dimensions to natural dimensions. Expand updates these - // to fill available space; collapse restores them to natural exactly. - imageDetails.renderWidth = imageWidth - imageDetails.renderHeight = imageHeight + // The image file's own size; how big it is drawn is the sizing's call + // (issue #345). Natural and render size are both set from it — expand + // updates the render size to fill available space, collapse restores it. + sizeLoadedImage(instance, instance.state, { width: tempImg.naturalWidth, height: tempImg.naturalHeight }) // Update SVG layout updateSVGLayout(instance) diff --git a/src/core/configuration.js b/src/core/configuration.js index e6d7fa76..f967d369 100644 --- a/src/core/configuration.js +++ b/src/core/configuration.js @@ -16,6 +16,7 @@ import { NORMALISATION_MODES } from '../audio/normalise.js' import { COLOUR_MAPS } from '../audio/colourMap.js' import { LEVEL_SCOPES } from '../audio/gramImage.js' import { numberParam, choiceParam, positiveParam } from './configValues.js' +import { IMAGE_SIZINGS, DEFAULT_IMAGE_SIZING, setImageSizing } from '../components/imageSizing.js' /** @typedef {import('./configValues.js').ParameterCell} ParameterCell */ @@ -92,6 +93,9 @@ function extractImageConfig(instance, imgElement, params) { } config.freqMin = freqStart config.freqMax = freqEnd + + // How big the image is drawn (issue #345). Absent, it is `screen`. + setImageSizing(instance, choiceParam(params, 'image-sizing', IMAGE_SIZINGS) || DEFAULT_IMAGE_SIZING) } /** diff --git a/src/core/viewport.js b/src/core/viewport.js index 18f708c8..a89ead4b 100644 --- a/src/core/viewport.js +++ b/src/core/viewport.js @@ -13,6 +13,7 @@ import { dispatch } from './state.js' import { screenToSVG, imageToData, getRenderDimensions } from '../utils/coordinates.js' import { anchorHoldingPoint, anchorForCentre, viewCentre } from '../utils/zoomAnchor.js' import { refreshExpandedLayout } from '../components/ExpandToggle.js' +import { refitImage } from '../components/imageSizing.js' import { isPlayerActive, clampViewTop, visibleWindowSeconds } from '../player/playerView.js' /** The zoom range the view is held within. Every zoom path clamps to it. */ @@ -287,7 +288,6 @@ export function zoomToRegion(instance, region) { setZoom(instance, level, anchorForCentre(centreX, level), anchorForCentre(centreY, level)) } - /** * Show the whole gram again: the Fit button (spec 170, FR-014), and the * recentring `zoomAtImagePoint` and `zoomAboutViewCentre` both do when a @@ -336,7 +336,8 @@ function updateZoomControlStates(instance) { */ export function handleResize(instance) { if (instance.ui.svg) { - // When expanded, recompute the available space so the image keeps filling it. + // Refit to the page's width (#345); when expanded, keep filling the space. + refitImage(instance, instance.state) refreshExpandedLayout(instance) updateSVGLayout(instance) renderAxes(instance) diff --git a/src/utils/imageFit.js b/src/utils/imageFit.js new file mode 100644 index 00000000..95edbf9d --- /dev/null +++ b/src/utils/imageFit.js @@ -0,0 +1,37 @@ +/** + * The size an image-backed gram is drawn at, as arithmetic (issue #345). Pure, + * so the unit lane covers every sizing; `components/imageSizing.js` measures + * the room and applies the answer. + */ + +// The legacy cap: images wider than this are scaled down to it. +const LEGACY_MAX_WIDTH = 1200 + +/** + * The size an image is drawn at, before zoom and expand. + * + * `legacy` keeps one image pixel per CSS pixel and caps the width at 1200, + * whatever room there is. `native` keeps one image pixel per CSS pixel and + * `screen` one per screen pixel; both only ever shrink, aspect kept, to fit the + * available width. + * @param {{width: number, height: number}} source - The image file's pixel size + * @param {string} sizing - `legacy`, `native` or `screen` + * @param {{availableWidth: number, pixelRatio: number}} room - The width the + * image area has, in CSS pixels (0 when unmeasurable), and the screen pixels + * per CSS pixel + * @returns {{width: number, height: number}} The base size, in CSS pixels + */ +export function fitImageSize(source, sizing, room) { + const { width, height } = source + if (sizing === 'legacy') { + if (width <= LEGACY_MAX_WIDTH) return { width, height } + return { width: LEGACY_MAX_WIDTH, height: Math.round(height * LEGACY_MAX_WIDTH / width) } + } + const ratio = sizing === 'screen' && room.pixelRatio > 0 ? room.pixelRatio : 1 + const fits = !(room.availableWidth > 0) || width / ratio <= room.availableWidth + const scale = fits ? 1 / ratio : room.availableWidth / width + return { + width: Math.max(1, Math.round(width * scale)), + height: Math.max(1, Math.round(height * scale)) + } +} diff --git a/tests/expand-image.spec.js b/tests/expand-image.spec.js index 96839fcb..6d5f7568 100644 --- a/tests/expand-image.spec.js +++ b/tests/expand-image.spec.js @@ -112,13 +112,16 @@ test.describe('Expand Image Toggle — User Story 1', () => { }) }) -test.describe('Expand Image Toggle — User Story 3 (portrait guard)', () => { - test('SC-005: no toggle for portrait vernier, toggle present for landscape', async ({ page }) => { +test.describe('Expand Image Toggle — User Story 3 (portrait)', () => { + // SC-005 withheld the toggle from portrait verniers. Issue #345 gives it to + // every image gram under the default `screen` sizing; `legacy` keeps the + // portrait guard, and tests/image-sizing.spec.js covers that. + test('SC-005: the toggle is present for landscape and portrait grams alike', async ({ page }) => { const landscape = await gotoDemo(page, LANDSCAPE_PAGE) expect(await landscape.isExpandToggleVisible()).toBe(true) const portrait = await gotoDemo(page, VERNIER_PAGE) - expect(await portrait.isExpandToggleVisible()).toBe(false) + expect(await portrait.isExpandToggleVisible()).toBe(true) }) }) diff --git a/tests/fixtures/image-sizing-page.html b/tests/fixtures/image-sizing-page.html new file mode 100644 index 00000000..22c513e6 --- /dev/null +++ b/tests/fixtures/image-sizing-page.html @@ -0,0 +1,56 @@ + + + + + + Image sizing trial - issue #345 + + + + + + +
Vernier spectrogram
+ + + + + + +
Large
time-start0
time-end60
freq-start0
freq-end500
image-sizinglegacy
+ + + + + + + +
Large
time-start0
time-end60
freq-start0
freq-end500
image-sizingnative
+ + + + + + + + +
Snippet
time-start0
time-end60
freq-start0
freq-end100
image-sizinglegacy
+ + + + + + + +
Snippet
time-start0
time-end60
freq-start0
freq-end100
image-sizingnative
+ + + + + + + +
Snippet
time-start0
time-end60
freq-start0
freq-end100
image-sizingsideways
+ + diff --git a/tests/image-scaling.spec.js b/tests/image-scaling.spec.js index c061400f..83b91bd3 100644 --- a/tests/image-scaling.spec.js +++ b/tests/image-scaling.spec.js @@ -1,6 +1,8 @@ /** * Image scaling tests for Issue #153 - * Tests automatic scaling of images wider than MAX_IMAGE_WIDTH (1200px) + * Tests the `legacy` sizing's automatic scaling of images wider than 1200px. + * The default is now `screen` (issue #345); debug-multiple.html pins its large + * gram to `legacy`, and tests/image-sizing.spec.js covers the other sizings. */ import { test, expect } from '@playwright/test' diff --git a/tests/image-sizing.spec.js b/tests/image-sizing.spec.js new file mode 100644 index 00000000..b8220139 --- /dev/null +++ b/tests/image-sizing.spec.js @@ -0,0 +1,81 @@ +/** + * The `image-sizing` trial row (issue #345): legacy caps a wide image at 1200 + * CSS pixels whatever the room; native fits it to the room, and follows a + * resize; an unknown value is reported like any other config mistake. + */ + +import { test, expect } from '@playwright/test' + +/** + * The drawn base width of each image-backed instance, once all have loaded. + * @param {import('@playwright/test').Page} page + * @returns {Promise} + */ +async function naturalWidths(page) { + let widths = /** @type {number[]} */ ([]) + await expect.poll(async () => { + widths = await page.evaluate(() => window.GramFrame.__test__getInstances() + .map((/** @type {any} */ i) => i.state.imageDetails.naturalWidth)) + return widths.length === 4 && widths.every(w => w > 0) + }).toBe(true) + return widths +} + +test.describe('image-sizing config row (issue #345)', () => { + test('native fits a wide gram to the room; legacy keeps its 1200 cap', async ({ page }) => { + await page.setViewportSize({ width: 1000, height: 800 }) + await page.goto('http://localhost:5173/tests/fixtures/image-sizing-page.html') + const [legacy, native] = await naturalWidths(page) + expect(legacy).toBe(1200) + expect(native).toBeLessThan(1000) + + // Nothing in the component scrolls sideways under native. + const svg = page.locator('.gram-frame-container').nth(1).locator('.gram-frame-svg') + const svgBox = await svg.boundingBox() + expect(svgBox && svgBox.x + svgBox.width).toBeLessThanOrEqual(1000) + + // A wider window gives it its full native width back; legacy is unmoved. + await page.setViewportSize({ width: 1900, height: 800 }) + await expect.poll(async () => (await naturalWidths(page))[1]).toBe(1677) + expect((await naturalWidths(page))[0]).toBe(1200) + }) + + test('a portrait snippet gets the expand toggle under the trial only, and expands', async ({ page }) => { + await page.setViewportSize({ width: 1400, height: 900 }) + await page.goto('http://localhost:5173/tests/fixtures/image-sizing-page.html') + await naturalWidths(page) + const containers = page.locator('.gram-frame-container') + await expect(containers.nth(2).locator('.gram-frame-expand-toggle')).toHaveCount(0) + const toggle = containers.nth(3).locator('.gram-frame-expand-toggle') + await expect(toggle).toHaveCount(1) + + await toggle.click() + await expect.poll(async () => page.evaluate(() => { + const details = window.GramFrame.__test__getInstances()[3].state.imageDetails + return (details.renderWidth || 0) > details.naturalWidth && (details.renderHeight || 0) >= details.naturalHeight + })).toBe(true) + + await toggle.click() + await expect.poll(async () => page.evaluate(() => { + const details = window.GramFrame.__test__getInstances()[3].state.imageDetails + return [details.renderWidth, details.renderHeight] + })).toEqual([357, 366]) + }) + + test('with no image-sizing row a gram is drawn one image pixel per screen pixel', async ({ browser }) => { + const context = await browser.newContext({ deviceScaleFactor: 2, viewport: { width: 1400, height: 900 } }) + const page = await context.newPage() + await page.goto('http://localhost:5173/sample/pub10-vernier.html') + // The vernier is 240 x 480 image pixels: 120 x 240 CSS pixels at 2x. + await expect.poll(async () => page.evaluate(() => { + const details = window.GramFrame.__test__getInstances()[0].state.imageDetails + return [details.naturalWidth, details.naturalHeight] + })).toEqual([120, 240]) + await context.close() + }) + + test('an unknown image-sizing value is reported on the page', async ({ page }) => { + await page.goto('http://localhost:5173/tests/fixtures/image-sizing-page.html') + await expect(page.locator('.gramframe-error-indicator').first()).toContainText('image-sizing') + }) +}) diff --git a/tests/unit/imageFit.test.js b/tests/unit/imageFit.test.js new file mode 100644 index 00000000..15b4a9d4 --- /dev/null +++ b/tests/unit/imageFit.test.js @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import { fitImageSize } from '../../src/utils/imageFit.js' + +// The two images from issue #345: a wide gram and a snippet cut from it. +const LARGE = { width: 1677, height: 380 } +const SNIPPET = { width: 357, height: 366 } + +describe('fitImageSize', () => { + it('legacy: native below 1200 CSS px, capped at 1200 above it, whatever the room', () => { + expect(fitImageSize(SNIPPET, 'legacy', { availableWidth: 200, pixelRatio: 1.5 })).toEqual(SNIPPET) + expect(fitImageSize(LARGE, 'legacy', { availableWidth: 2000, pixelRatio: 1 })).toEqual({ width: 1200, height: 272 }) + }) + + it('native: one image pixel per CSS pixel when it fits', () => { + expect(fitImageSize(LARGE, 'native', { availableWidth: 1700, pixelRatio: 1.5 })).toEqual(LARGE) + expect(fitImageSize(SNIPPET, 'native', { availableWidth: 1000, pixelRatio: 2 })).toEqual(SNIPPET) + }) + + it('native: shrinks to the available width, keeping the aspect ratio', () => { + expect(fitImageSize(LARGE, 'native', { availableWidth: 1000, pixelRatio: 1 })).toEqual({ width: 1000, height: 227 }) + }) + + it('screen: one image pixel per screen pixel', () => { + expect(fitImageSize(SNIPPET, 'screen', { availableWidth: 1000, pixelRatio: 1.5 })).toEqual({ width: 238, height: 244 }) + expect(fitImageSize(SNIPPET, 'screen', { availableWidth: 1000, pixelRatio: 1 })).toEqual(SNIPPET) + }) + + it('screen: still shrinks to fit when even the screen-pixel size is too wide', () => { + expect(fitImageSize(LARGE, 'screen', { availableWidth: 800, pixelRatio: 1.25 })).toEqual({ width: 800, height: 181 }) + }) + + it('never enlarges, and keeps native when the room cannot be measured', () => { + expect(fitImageSize(SNIPPET, 'native', { availableWidth: 0, pixelRatio: 1 })).toEqual(SNIPPET) + expect(fitImageSize(SNIPPET, 'screen', { availableWidth: 0, pixelRatio: 0 })).toEqual(SNIPPET) + }) +})