Skip to content
Merged
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
- **HDF5 browser — large files:** opening an HDF5 file no longer reads every dataset entirely, including those that cannot be imported (e.g. 3D cubes): datasets are described from a small sample, and their data is read only when previewed or imported. Large files open faster and with much less memory.
- **Embedding — message origins:** the JavaScript SDK accepted responses and events from any document loaded in the DataLab-Web iframe, and DataLab-Web sent its events to host windows whatever document they showed. The SDK now only accepts messages from the configured `targetOrigin`, and DataLab-Web sends its responses and events to the origin from which each host window made its requests.
- **Image viewer — pixel positions:** images were drawn half a pixel off their coordinates: the first pixel started at the image origin instead of being centered on it, as in Sigima and DataLab desktop. Regions of interest, analysis results, the profile crosshair and the statistics rectangle therefore appeared shifted by half a pixel relative to the computed pixels, and the value shown when hovering the image could be that of the neighbouring pixel. Each pixel is now centered on its coordinates in the image viewer, the multi-image views and the processing preview, so overlays, read-outs, cross-sections and statistics match the computations exactly. The default regions of interest and areas to erase are centered on the image, and the preview of _Create ROI grid…_ now shows the same whole-pixel cells as the created regions of interest. Cross-sections and statistics also work on images with non-uniform coordinates.
- **Image viewer — blurred pixels when zooming in:** since version 0.6.1, zooming in on an image showed blurred pixels, each one fading into its neighbours, so that close values could not be told apart. Zoomed-in pixels are now drawn as sharp squares (nearest pixel, as in DataLab desktop) in the image viewer, the _Spatial_ multi-image view and the processing preview, in every browser and in the images exported from the plot. Zoomed-out images are displayed as before.

### Changed

Expand Down
18 changes: 17 additions & 1 deletion src/components/ImagePlot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ import {
shouldUseLod,
} from "../utils/imageLod";
import {
CRISP_PIXELS_CLASS,
type ImageRasterResult,
RASTER_DEBOUNCE_MS,
showsCrispPixels,
normalizeResampleMethod,
rasterLayoutImage,
rasterizeImage,
Expand Down Expand Up @@ -770,6 +772,20 @@ export function ImagePlot({
: [],
[bitmap, data.is_uniform_coords],
);
// Zoomed in, the bitmap is magnified into sharp pixel blocks; the browser
// must not smooth what remains of its enlargement on screen.
const crispPixels = useMemo(
() =>
bitmap !== null &&
data.is_uniform_coords !== false &&
showsCrispPixels(
bitmap,
displayRange,
plotPx,
window.devicePixelRatio || 1,
),
[bitmap, data.is_uniform_coords, displayRange, plotPx],
);

const layout = useMemo(() => {
const xtitle = data.xunit ? `${data.xlabel} (${data.xunit})` : data.xlabel;
Expand Down Expand Up @@ -1522,7 +1538,7 @@ export function ImagePlot({
? " profiles-frozen"
: " profiles-live"
: ""
}`}
}${crispPixels ? ` ${CRISP_PIXELS_CLASS}` : ""}`}
style={{ position: "relative", width: "100%", height: "100%" }}
onMouseDownCapture={handleHostMouseDownCapture}
onMouseMove={handleWrapperMouseMove}
Expand Down
19 changes: 18 additions & 1 deletion src/components/MultiImageSpatialPlot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,10 @@ import { usePlotlyTheme } from "../utils/plotlyTheme";
import { buildColorscale } from "../utils/colormap";
import { aspectFitRanges, type ViewRange } from "../utils/imageLod";
import {
CRISP_PIXELS_CLASS,
type ImageRasterResult,
RASTER_DEBOUNCE_MS,
showsCrispPixels,
normalizeResampleMethod,
rasterLayoutImage,
rasterizeImage,
Expand Down Expand Up @@ -254,6 +256,19 @@ function MultiImageSpatialPlotImpl({ images, totalSelected }: Props) {
}
return out;
}, [uniqueImages, rasters]);
// Nearest-pixel scaling only when every visible bitmap is enlarged on
// screen (typically when zoomed into one image): a reduced bitmap would be
// aliased by it.
const crispPixels = useMemo(() => {
const shown = Object.values(rasters);
const dpr = window.devicePixelRatio || 1;
return (
shown.length > 0 &&
shown.every((raster) =>
showsCrispPixels(raster, displayRange, plotPx, dpr),
)
);
}, [rasters, displayRange, plotPx]);

// ------------------------------------------------------------------
// Custom hover: ``image`` traces emit no ``plotly_hover`` events, so
Expand Down Expand Up @@ -520,7 +535,9 @@ function MultiImageSpatialPlotImpl({ images, totalSelected }: Props) {
</div>
)}
<div
className="multi-image-spatial-wrap"
className={`multi-image-spatial-wrap${
crispPixels ? ` ${CRISP_PIXELS_CLASS}` : ""
}`}
onMouseDownCapture={handlePanMouseDown}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
Expand Down
7 changes: 7 additions & 0 deletions src/components/ProcessingPreviewPlot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import type { ProcessingPreviewResult } from "../runtime/runtime";
import { buildCurveTrace, getCurveStyle } from "../runtime/plotStyles";
import { buildColorscale } from "../utils/colormap";
import { toBins } from "../utils/imageCoords";
import { isMagnified } from "../utils/imageLod";
import {
CRISP_PIXELS_CLASS,
normalizeResampleMethod,
rasterLayoutImage,
rasterizeImage,
Expand Down Expand Up @@ -149,8 +151,13 @@ function ImagePreviewPlot({ result }: { result: ImagePreviewResult }) {
];
const extent = imageExtent(data);
const images = raster && !nonUniform ? [rasterLayoutImage(raster)] : [];
// The preview does not track the plot size: nearest-pixel scaling is only
// safe for magnified bitmaps (blocks of at least two pixels per side).
const crispPixels =
raster !== null && !nonUniform && isMagnified(raster.plan);
return (
<Plot
className={crispPixels ? CRISP_PIXELS_CLASS : undefined}
data={traces as Data[]}
layout={{
...theme,
Expand Down
13 changes: 13 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -5552,6 +5552,19 @@ input {
cursor: pointer;
}

/* Nearest-pixel scaling of the image bitmaps (Plotly layout images), set by
the image views only while a bitmap is enlarged on screen: zoomed-in pixels
stay sharp squares, like DataLab desktop. Never set on reduced bitmaps,
which nearest-pixel downscaling would alias. Same declarations as Plotly's
own pixelated image style, most widely supported last. */
.image-crisp-pixels .layer-subplot .imagelayer image {
image-rendering: optimizeSpeed;
image-rendering: -moz-crisp-edges;
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
image-rendering: pixelated;
}

/* Small "Extract" button overlaid on each frozen profile preview; mirrors
DataLab desktop's "Process signal" button next to the cross-section
panels. */
Expand Down
57 changes: 39 additions & 18 deletions src/utils/imageLod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
*
* The functions below are **pure** (no DOM, no Plotly) so they can be unit
* tested exhaustively. They compute, from the current axis ranges and the
* on-screen plot size, *which* source pixels are visible and at *what* stride
* they should be rasterised, plus the exact image-trace placement so the
* decimated bitmap lands on the same physical coordinates as the full image.
* on-screen plot size, *which* source pixels are visible, at *what* stride
* they should be rasterised — or by what integer factor they should be
* magnified when zoomed in, so that they stay crisp — plus the exact
* placement so the bitmap lands on the same physical coordinates as the
* full image.
*
* Invariant: these helpers only drive the **display bitmap**. Profiles,
* statistics, histograms and hover read-outs always read the full-resolution
Expand Down Expand Up @@ -48,18 +50,30 @@ export interface PixelWindow {
j1: number;
}

/** Rasterisation plan: which window, at what stride, producing a
* ``cw × ch`` output bitmap. */
/** Rasterisation plan: which window, at what stride, painted as a ``cw × ch``
* bitmap, then magnified by integer factors when zoomed in. */
export interface RasterPlan {
/** Top-left source pixel of the window. */
i0: number;
j0: number;
/** Output bitmap dimensions (pixels actually rasterised). */
/** Painted bitmap dimensions (cells actually rasterised). */
cw: number;
ch: number;
/** Number of source pixels aggregated per output cell along each axis. */
strideX: number;
strideY: number;
/** Integer magnification of each painted cell into ``zoomX × zoomY``
* bitmap pixels (nearest pixel), so that the browser never has to upscale
* — and smooth — a zoomed-in image. ``1`` when not zoomed in. */
zoomX: number;
zoomY: number;
}

/** Whether a plan magnifies its painted cells (zoomed in). */
export function isMagnified(
plan: Pick<RasterPlan, "zoomX" | "zoomY">,
): boolean {
return plan.zoomX > 1 || plan.zoomY > 1;
}

/** How source pixels are aggregated into one output cell when downsampling
Expand Down Expand Up @@ -126,13 +140,16 @@ export function visibleWindow(
}

/**
* Decide the rasterisation stride and output bitmap size for a window.
* Decide the rasterisation stride, magnification and bitmap size for a window.
*
* When the window is smaller than the available display resolution (the user
* is zoomed in) the stride is 1 and the output matches the window 1:1 — every
* source pixel is shown. When the window is larger (zoomed out) the stride is
* the smallest integer that brings the output at or below the display
* resolution, capped at ``maxDim``.
* When the window is larger than the available display resolution (zoomed
* out) the stride is the smallest integer that brings the output at or below
* the display resolution, capped at ``maxDim``. Otherwise the stride is 1 —
* every source pixel is painted — and when each source pixel covers at least
* two device pixels (zoomed in), the painted cells are magnified by the
* largest integer factor that keeps the bitmap within the display
* resolution, so each pixel is drawn as a sharp block (nearest pixel, like
* DataLab desktop) instead of being upscaled — and smoothed — by the browser.
*
* @param win Visible pixel window.
* @param plotW On-screen plot-area width in CSS pixels.
Expand All @@ -155,22 +172,26 @@ export function rasterPlan(
const strideY = ny > availY ? Math.ceil(ny / availY) : 1;
const cw = Math.ceil(nx / strideX);
const ch = Math.ceil(ny / strideY);
return { i0: win.i0, j0: win.j0, cw, ch, strideX, strideY };
// ``avail`` is capped at ``maxDim``, so ``cw * zoomX <= maxDim``.
const zoomX = strideX === 1 ? Math.max(1, Math.floor(availX / cw)) : 1;
const zoomY = strideY === 1 ? Math.max(1, Math.floor(availY / ch)) : 1;
return { i0: win.i0, j0: win.j0, cw, ch, strideX, strideY, zoomX, zoomY };
}

/**
* Physical placement of the rasterised bitmap as a Plotly layout image.
*
* Each output cell spans ``strideX × strideY`` source pixels, so the cell
* size is ``dx*strideX`` / ``dy*strideY``, and the bitmap's upper-left corner
* ``(x, y)`` is the outer edge of the first source pixel of the window — half a
* pixel before its center.
* Each painted cell spans ``strideX × strideY`` source pixels, so the cell
* size is ``dx*strideX`` / ``dy*strideY`` (magnification only splits a cell
* into more bitmap pixels, the cell keeps its physical size), and the
* bitmap's upper-left corner ``(x, y)`` is the outer edge of the first source
* pixel of the window — half a pixel before its center.
*
* @param plan Rasterisation plan from :func:`rasterPlan`.
* @param geom Uniform-image geometry.
*/
export function windowPlacement(
plan: RasterPlan,
plan: Pick<RasterPlan, "i0" | "j0" | "strideX" | "strideY">,
geom: ImageGeometry,
): { x: number; dx: number; y: number; dy: number } {
return {
Expand Down
Loading
Loading