diff --git a/treescope/_internal/api/array_autovisualizer.py b/treescope/_internal/api/array_autovisualizer.py index 4273904..93660d3 100644 --- a/treescope/_internal/api/array_autovisualizer.py +++ b/treescope/_internal/api/array_autovisualizer.py @@ -70,6 +70,11 @@ class ArrayAutovisualizer: visualization. Useful for seeing small array values. token_lookup_fn: Optional function that looks up token IDs and adds them to the visualization on hover. + pixels_per_cell: Size of each rendered array element in pixels, between 1 + and 21 inclusive. This controls the zoom level of the rendering. Array + elements are always drawn at 7 pixels per cell and then rescaled, so + out-of-bounds annotations and "digitbox" integer value patterns may not + display correctly at fewer than 7 pixels per cell. """ maximum_size: int = 4_000 @@ -81,6 +86,7 @@ class ArrayAutovisualizer: force_continuous: bool = False include_repr_line_threshold: int = 5 token_lookup_fn: Callable[[int], str] | None = None + pixels_per_cell: int | float = 7 def _autovisualize_array( self, @@ -159,6 +165,7 @@ def _autovisualize_array( axis_item_labels=None, value_item_labels=value_item_labels, axis_labels=None, + pixels_per_cell=self.pixels_per_cell, ) outputs = [array_rendering] last_line_parts = [] diff --git a/treescope/_internal/api/arrayviz.py b/treescope/_internal/api/arrayviz.py index e7ae22e..2a1ad8f 100644 --- a/treescope/_internal/api/arrayviz.py +++ b/treescope/_internal/api/arrayviz.py @@ -126,7 +126,7 @@ def render_array( axis_item_labels: dict[AxisName | int, list[str]] | None = None, value_item_labels: dict[int, str] | None = None, axis_labels: dict[AxisName | int, str] | None = None, - pixels_per_cell: int = 7, + pixels_per_cell: int | float = 7, ) -> figures_impl.TreescopeFigure: """Renders an array (positional or named) to a displayable HTML object. @@ -386,7 +386,7 @@ def _render_pretruncated( axis_item_labels: dict[AxisName | int, list[str]] | None, value_item_labels: dict[int, str] | None, axis_labels: dict[AxisName | int, str] | None, - pixels_per_cell: int = 7, + pixels_per_cell: int | float = 7, ) -> arrayviz_impl.ArrayvizRendering: """Internal helper to render an array that has already been truncated.""" if axis_item_labels is None: diff --git a/treescope/_internal/arrayviz_impl.py b/treescope/_internal/arrayviz_impl.py index c9155e3..5803ca0 100644 --- a/treescope/_internal/arrayviz_impl.py +++ b/treescope/_internal/arrayviz_impl.py @@ -142,7 +142,7 @@ def render_array_data_to_html( dynamic_continous_cmap: bool = False, raw_min_abs: float | None = None, raw_max_abs: float | None = None, - pixels_per_cell: int = 7 + pixels_per_cell: int | float = 7 ) -> str: """Helper to render an array to HTML by passing arguments to javascript. diff --git a/treescope/_internal/js/arrayviz.js b/treescope/_internal/js/arrayviz.js index 9306717..370a8b1 100644 --- a/treescope/_internal/js/arrayviz.js +++ b/treescope/_internal/js/arrayviz.js @@ -896,11 +896,9 @@ const arrayviz = (() => { container.style.width = 'fit-content'; container.style.height = 'fit-content'; - container.style.setProperty('image-rendering', 'pixelated'); container.style.fontFamily = 'monospace'; container.style.transformOrigin = 'top left'; container.style.setProperty('image-rendering', 'pixelated'); - container.style.setProperty('--arrayviz-zoom', '1'); container.style.setProperty('--base-font-size', '14px'); @@ -1013,8 +1011,8 @@ const arrayviz = (() => { const datalist = /** @type {!HTMLDataListElement} */ ( destination.appendChild(document.createElement('datalist'))); - datalist.id = "arrayviz-markers"; - for (const val of [1, 7, 14, 21]) { + datalist.id = 'arrayviz-markers'; + for (const val of [1, 2, 3.5, 7, 14, 21]) { const datalistOption = /** @type {!HTMLOptionElement} */ ( datalist.appendChild(document.createElement('option'))); datalistOption.value = val; @@ -1022,10 +1020,10 @@ const arrayviz = (() => { const zoomslider = /** @type {!HTMLInputElement} */ (document.createElement('input')); zoomslider.type = 'range'; - zoomslider.value = pixelsPerCell; zoomslider.min = '1'; zoomslider.max = '21'; - zoomslider.step = '1'; + zoomslider.step = 'any'; + zoomslider.value = pixelsPerCell; zoomslider.setAttribute('list', datalist.id); const infodiv = /** @type {!HTMLDivElement} */ ( destination.appendChild(document.createElement('div'))); @@ -1089,18 +1087,48 @@ const arrayviz = (() => { // Event handlers. let pendingZoomPixels = 0; + const updateZoomFromSlider = () => { + // For accurate pixel alignment: Round device pixel ratio to an integer + // so that zoom level 1 is an integer number of pixels. + let roundedPxRatio = Math.round(window.devicePixelRatio); + if (window.devicePixelRatio < 1) { + roundedPxRatio = 1 / Math.round(1 / window.devicePixelRatio); + } + const pixelRatioAdjustment = roundedPxRatio / window.devicePixelRatio; + // For accurate pixel alignment: Round to the closest number of physical + // pixels, then map back to logical scale. + let cssPxTarget = parseFloat(zoomslider.value); + if (cssPxTarget < 1) { + cssPxTarget = 1; + } + if ((cssPxTarget * roundedPxRatio) % cellSize == 0) { + container.style.setProperty('image-rendering', 'pixelated'); + } else { + container.style.setProperty('image-rendering', 'auto'); + } + const scale = (cssPxTarget / cellSize) * pixelRatioAdjustment; + container.style.setProperty('--arrayviz-zoom', `${scale}`); + fixMargins(); + }; + const watchPixelRatio = () => { + const media = + window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); + const listener = () => { + media.removeEventListener('change', listener); + updateZoomFromSlider(); + watchPixelRatio(); + }; + media.addEventListener('change', listener); + }; zoomslider.addEventListener('input', () => { pendingZoomPixels = 0; const oldTop = zoomslider.offsetTop; - const scale = parseFloat(zoomslider.value) / cellSize; - container.style.setProperty('--arrayviz-zoom', `${scale}`); - fixMargins(); + updateZoomFromSlider(); // Try to keep it in a consistent place: window.scrollBy( {top: zoomslider.offsetTop - oldTop, behavior: 'instant'}); // In case that didn't work, make sure it's at least visible: zoomslider.scrollIntoView({behavior: 'instant', block: 'nearest'}); - console.log('Fixing scroll'); }); canvas.addEventListener('mousemove', (evt) => { const lookup = lookupPixel(evt.offsetX, evt.offsetY); @@ -1189,9 +1217,7 @@ const arrayviz = (() => { } } zoomslider.value = curZoomLevel; - const scale = curZoomLevel / cellSize; - container.style.setProperty('--arrayviz-zoom', `${scale}`); - fixMargins(); + updateZoomFromSlider(); // Try to keep it in a consistent place. offsetX/offsetY are relative to // the zoom level, so we have to scale them. const baseRect = inner.getBoundingClientRect(); @@ -1207,8 +1233,8 @@ const arrayviz = (() => { // Draw the initial output. drawAllCells(); - fixMargins(); - zoomslider.dispatchEvent(new Event('input')); + updateZoomFromSlider(); + watchPixelRatio(); } /**