diff --git a/frontend/app/components/map/TileLayer.test.tsx b/frontend/app/components/map/TileLayer.test.tsx index 5352ca6d..8cd45f2b 100644 --- a/frontend/app/components/map/TileLayer.test.tsx +++ b/frontend/app/components/map/TileLayer.test.tsx @@ -379,12 +379,12 @@ describe("TileLayer", () => { return { sharp, clip: backdrop.parentElement!.style.clipPath }; }; - // An opaque tile reaches one screen pixel (here 1/256 of itself) under + // An opaque tile reaches two screen pixels (here 2/256 of itself) under // its neighbours, and the backdrop runs on 8 px under the sharp tiles: // where two edges meet off a pixel boundary the page would show through. const opaque = layer(false); expect(opaque.sharp.classList.contains("map-tile")).toBe(true); - expect(opaque.sharp.style.getPropertyValue("--tile-ox")).toBe(String(1 / 256)); + expect(opaque.sharp.style.getPropertyValue("--tile-ox")).toBe(String(2 / 256)); expect(opaque.clip).toContain("0.390625% 0.390625%, 0.390625% 24.609375%"); cleanup(); @@ -393,4 +393,29 @@ describe("TileLayer", () => { expect(seeThrough.sharp.classList.contains("map-tile")).toBe(false); expect(seeThrough.clip).toContain("0% 0%, 0% 25%, 25% 25%, 25% 0%, 0% 0%"); }); + + it("keeps opaque tiles in place and snaps only see-through ones to pixels", () => { + // At 0.7 a tile's edge at map x 256 is 179.2 screen pixels in. + const placed = (seeThrough: boolean) => { + const { container } = render( + + ); + const tile = container.querySelector('img[src="/t/0/1/0.webp"]')!; + const { translate, width } = tile.style; + cleanup(); + return { translate, width }; + }; + + // Snapped to the pixels of each zoom, a tile moved when a zoom settled. + expect(placed(false)).toEqual({ translate: "256px 0px", width: "256px" }); + // 179 screen pixels, which see-through neighbours meet on exactly. + const snapped = placed(true); + expect(parseFloat(snapped.translate) * 0.7).toBeCloseTo(179, 9); + expect(parseFloat(snapped.width) * 0.7).toBeCloseTo(179, 9); + }); }); diff --git a/frontend/app/components/map/TileLayer.tsx b/frontend/app/components/map/TileLayer.tsx index 0d6d17af..d3993411 100644 --- a/frontend/app/components/map/TileLayer.tsx +++ b/frontend/app/components/map/TileLayer.tsx @@ -32,10 +32,10 @@ const FADE_MS = 160; const SHOWN_MS = FADE_MS + 40; /** * How far, in screen pixels, an opaque tile reaches under its right and - * bottom neighbours at rest; twice that mid-zoom (see `.map-tile` in - * globals.css). + * bottom neighbours, at rest and mid-zoom alike (see `.map-tile` in + * globals.css). One pixel let a seam through mid-zoom. */ -const TILE_OVERLAP_PX = 1; +const TILE_OVERLAP_PX = 2; /** * How far, in screen pixels, the backdrop runs on under the edge of opaque * sharp tiles (see the backdrop's clip path below). @@ -222,8 +222,6 @@ function TileLayer({ image.decode().then(settle, settle); }; - // Snap tile edges to whole screen pixels so neighbours meet without gaps - // or overlaps, which would darken see-through rasters such as prosperity. // The position goes in a transform, not left/top: at a fractional device // pixel ratio (Windows at 125 %, say) Chrome painted a box placed by // left/top up to a quarter pixel off its snapped edge, so the first column @@ -231,12 +229,18 @@ function TileLayer({ // dark lines. A translate keeps the exact position. The size still goes // through `zoom` and can end a hair short of the pixel, and mid-zoom the // edges land between pixels anyway; Chrome lets about a quarter of such a - // pixel through. So opaque tiles also reach a little under their right and - // bottom neighbours, which are drawn after them (on top). + // pixel through. So opaque tiles reach a little under their right and + // bottom neighbours, which are drawn after them (on top), and keep their + // exact place: snapped to the pixels of each new zoom they moved by up to + // a pixel, each its own way, when a zoom settled. + // + // See-through rasters such as prosperity cannot overlap, which would + // darken the strip, so their edges snap to whole screen pixels to meet + // without gaps. const screenDpr = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1; const mapPxPerScreenPx = 1 / (view.displayScale * screenDpr); const snap = (value: number) => - mapPxPerScreenPx > 0 && Number.isFinite(mapPxPerScreenPx) + seeThrough && mapPxPerScreenPx > 0 && Number.isFinite(mapPxPerScreenPx) ? Math.round(value / mapPxPerScreenPx) * mapPxPerScreenPx : value; diff --git a/frontend/app/hooks/useMapViewport.test.ts b/frontend/app/hooks/useMapViewport.test.ts index e3afa225..bd2c206b 100644 --- a/frontend/app/hooks/useMapViewport.test.ts +++ b/frontend/app/hooks/useMapViewport.test.ts @@ -1,6 +1,19 @@ -import { describe, expect, it } from "vitest"; +/** + * @vitest-environment jsdom + */ -import { readViewportSize } from "./useMapViewport"; +import { act, cleanup, fireEvent, render } from "@testing-library/react"; +import { createElement } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { readViewportSize, useMapViewport } from "./useMapViewport"; + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); describe("readViewportSize", () => { it("returns width and height from getBoundingClientRect", () => { @@ -21,3 +34,62 @@ describe("readViewportSize", () => { expect(readViewportSize(element)).toEqual({ w: 1200, h: 800 }); }); }); + +// Stable, as callers keep it: a new size each render re-clamps each render. +const MAP_SIZE = { w: 6400, h: 6400 }; + +function Viewport() { + const viewport = useMapViewport({ mapSize: MAP_SIZE, restingZoom: true }); + return createElement( + "div", + { ref: viewport.viewportRef, "data-testid": "viewport" }, + createElement("div", { + ref: viewport.contentRef, + "data-testid": "content", + "data-zoom": viewport.zoom, + style: { transform: viewport.transformStyle }, + }) + ); +} + +/** The translate in a `translate(x, y) scale(s)` transform, in device pixels. */ +function devicePixels(transform: string, ratio: number): number[] { + const match = /translate\(([-\d.e]+)px, ([-\d.e]+)px\)/.exec(transform); + return match ? [Number(match[1]) * ratio, Number(match[2]) * ratio] : []; +} + +describe("useMapViewport", () => { + it("keeps a wheel zoom on whole device pixels, where it settles", () => { + vi.useFakeTimers(); + vi.stubGlobal("devicePixelRatio", 1.25); + vi.stubGlobal("ResizeObserver", class { observe() {} disconnect() {} unobserve() {} }); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ + width: 777, + height: 555, + left: 0, + top: 0, + right: 777, + bottom: 555, + x: 0, + y: 0, + toJSON: () => ({}), + }); + const { getByTestId } = render(createElement(Viewport)); + const content = getByTestId("content"); + + fireEvent.wheel(getByTestId("viewport"), { deltaY: -100, clientX: 333.3, clientY: 201.7 }); + const live = content.style.transform; + expect(live).not.toContain("scale(1)"); + for (const value of devicePixels(live, 1.25)) expect(value).toBeCloseTo(Math.round(value), 9); + expect(content.style.getPropertyValue("--map-live-scale")).not.toBe(""); + + // Settled, the scale moves into `zoom` and the map stays where it was. + act(() => { + vi.advanceTimersByTime(500); + }); + const rest = content.style.transform; + expect(rest).toContain("scale(1)"); + expect(devicePixels(rest, 1.25)).toEqual(devicePixels(live, 1.25)); + expect(content.style.getPropertyValue("--map-live-scale")).toBe(""); + }); +}); diff --git a/frontend/app/hooks/useMapViewport.ts b/frontend/app/hooks/useMapViewport.ts index e05cd6c6..eed02000 100644 --- a/frontend/app/hooks/useMapViewport.ts +++ b/frontend/app/hooks/useMapViewport.ts @@ -195,11 +195,9 @@ function onDevicePixel(value: number): number { */ function markScaling(content: HTMLElement, scale: number | null): void { if (scale === null) { - content.removeAttribute("data-scaling"); content.style.removeProperty("--map-live-scale"); return; } - content.setAttribute("data-scaling", ""); content.style.setProperty("--map-live-scale", String(scale)); } @@ -298,11 +296,13 @@ export function useMapViewport({ // rasterise one enormous texture, which measured slower than letting it // re-tile. content.style.transition = "none"; - // A drag only moves the map: like the resting position (see below) it - // sits on whole device pixels, or every tile edge straddles a pixel and - // the seams between tiles show as a grid of dark lines while it moves. + // Like the resting position (see below) the live one sits on whole + // device pixels. A drag only moves the map, and off them every tile + // edge straddles a pixel and the seams show as a grid of dark lines + // while it moves. Mid-zoom the same rounding puts the tiles exactly + // where the zoom will rest, so nothing moves when it settles. const liveScale = displayScale / appliedZoomRef.current; - const pixelAligned = restingZoomRef.current && liveScale === 1; + const pixelAligned = restingZoomRef.current; content.style.transform = viewportTransformStyle( liveScale, pixelAligned ? onDevicePixel(next.translateX) : next.translateX, @@ -848,23 +848,25 @@ export function useMapViewport({ setZoomBase(displayScale); }, [restingZoom, displayScale, transition, isPanning, zoomBase]); - // At rest the position sits on a whole device pixel, so the tiles' snapped - // edges (see TileLayer) land on pixel boundaries too; a sub-pixel shift no - // one can see. // An animated zoom runs from the zoom held so far to the new scale: size // the tiles' overlap for the smaller end, so it covers the whole way. const restingScale = displayScale / appliedZoom; + const overlapScale = transition === undefined ? restingScale : Math.min(1, restingScale); useLayoutEffect(() => { const content = contentRef.current; if (!content || liveActiveRef.current) return; - markScaling(content, restingZoom && restingScale !== 1 ? Math.min(1, restingScale) : null); + markScaling(content, restingZoom && restingScale !== 1 ? overlapScale : null); }); - const restingOnZoom = restingZoom && appliedZoom === displayScale; + // At rest the position sits on a whole device pixel, so the see-through + // tiles' snapped edges (see TileLayer) land on pixel boundaries too; a + // sub-pixel shift no one can see. So does the render that commits a + // gesture, before `zoom` catches up: it keeps the position the gesture + // ended on. const transformStyle = viewportTransformStyle( displayScale / appliedZoom, - restingOnZoom ? onDevicePixel(transform.translateX) : transform.translateX, - restingOnZoom ? onDevicePixel(transform.translateY) : transform.translateY + restingZoom ? onDevicePixel(transform.translateX) : transform.translateX, + restingZoom ? onDevicePixel(transform.translateY) : transform.translateY ); const cursorClassName = isPanning ? "cursor-grabbing" : "cursor-grab"; const transformTransition = isPanning ? undefined : transition; diff --git a/frontend/app/internal/globals.css b/frontend/app/internal/globals.css index 43b84ee9..c3c2890a 100644 --- a/frontend/app/internal/globals.css +++ b/frontend/app/internal/globals.css @@ -447,15 +447,12 @@ html.map-fullscreen textarea { * map scaled past its resting size) between pixels; Chrome then lets * about a quarter of that pixel through and the page shows as a grid of * dark lines. So an opaque tile reaches TILE_OVERLAP_PX (TileLayer) under - * its right and bottom neighbours, and twice that mid-zoom, divided by - * the live scale to stay that size on screen. */ + * its right and bottom neighbours, divided by the live scale to stay that + * size on screen. The same size mid-zoom and at rest: when it shrank as a + * zoom settled, every tile's far side moved back a pixel. */ .map-tile { - scale: calc(1 + var(--tile-ox)) calc(1 + var(--tile-oy)); - } - - [data-scaling] .map-tile { - scale: calc(1 + 2 * var(--tile-ox) / var(--map-live-scale)) - calc(1 + 2 * var(--tile-oy) / var(--map-live-scale)); + scale: calc(1 + var(--tile-ox) / var(--map-live-scale, 1)) + calc(1 + var(--tile-oy) / var(--map-live-scale, 1)); } /* An in-text link to another realm or place. */