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
29 changes: 27 additions & 2 deletions frontend/app/components/map/TileLayer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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(
<TileLayer
manifest={manifest("v1")}
tileUrl={tileUrl}
view={{ ...view, displayScale: 0.7 }}
seeThrough={seeThrough}
/>
);
const tile = container.querySelector<HTMLImageElement>('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);
});
});
20 changes: 12 additions & 8 deletions frontend/app/components/map/TileLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -222,21 +222,25 @@ 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
// and row of every tile let the page through and the map showed a grid of
// 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;

Expand Down
76 changes: 74 additions & 2 deletions frontend/app/hooks/useMapViewport.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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("");
});
});
28 changes: 15 additions & 13 deletions frontend/app/hooks/useMapViewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
13 changes: 5 additions & 8 deletions frontend/app/internal/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down