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. */