From a9001cb6597a83458b8b05a0d55b3185f575d34b Mon Sep 17 00:00:00 2001 From: darthmaim Date: Sun, 20 Apr 2025 20:59:44 +0200 Subject: [PATCH] Add support for asymmetric padding --- packages/tyria/dev/index.html | 5 +- packages/tyria/src/Tyria.ts | 106 +++++++++++++++---------- packages/tyria/src/layer.ts | 5 +- packages/tyria/src/layers/TileLayer.ts | 10 +-- packages/tyria/src/types.ts | 6 ++ 5 files changed, 83 insertions(+), 49 deletions(-) diff --git a/packages/tyria/dev/index.html b/packages/tyria/dev/index.html index 203abce..33e1e97 100644 --- a/packages/tyria/dev/index.html +++ b/packages/tyria/dev/index.html @@ -88,6 +88,7 @@ debug overlays + @@ -101,9 +102,8 @@ maxZoom: 7, minZoom: 1, zoomSnap: .5, - padding: 80, bounds: [[0, 0], [81920, 114688]], - // padding: { top: 16, bottom: 80, left: 16, right: 80 }, + padding: { top: 16, bottom: 80, left: 16, right: 80 }, }); map.addLayer(new TileLayer({ @@ -143,6 +143,7 @@ document.getElementById('debug').addEventListener('change', (e) => map.setDebug(e.target.checked)); document.getElementById('lionsarch').addEventListener('click', () => map.easeTo({ contain: [[48130, 30720], [50430, 32250]] })) + document.getElementById('lionsarch2').addEventListener('click', () => map.easeTo({ contain: [[48130, 30720], [50430, 32250]], padding: { top: 16, right: 80, bottom: 80, left: 1000 } })) document.getElementById('ascalon').addEventListener('click', () => map.easeTo({ contain: [[56682, 24700], [64500, 35800]], zoom: 3 })) document.getElementById('horn').addEventListener('click', () => map.easeTo({ contain: [[19328, 19048], [27296, 24800]] })) document.getElementById('cantha').addEventListener('click', () => map.easeTo({ contain: [[20576, 97840], [39056, 106256]] })) diff --git a/packages/tyria/src/Tyria.ts b/packages/tyria/src/Tyria.ts index 23e31bc..bb44c03 100644 --- a/packages/tyria/src/Tyria.ts +++ b/packages/tyria/src/Tyria.ts @@ -4,7 +4,7 @@ import { ImageManager } from './image-manager'; import { Layer, LayerHitTestContext, LayerPreloadContext, LayerRenderContext } from './layer'; import { TyriaMapOptions } from './options'; import { RenderQueue, RenderQueuePriority, RenderReason } from './render-queue'; -import { Bounds, Point, View, ViewOptions } from './types'; +import { Bounds, Padding, Point, View, ViewOptions } from './types'; import { add, clamp, easeInOutCubic, getPadding, multiply, subtract } from './util'; export class Tyria extends TyriaEventTarget { @@ -13,7 +13,8 @@ export class Tyria extends TyriaEventTarget { view: Readonly = { center: [0, 0], - zoom: 1 + zoom: 1, + padding: { top: 0, right: 0, bottom: 0, left: 0 }, } layers: { id: number, layer: Layer }[] = []; debug = false @@ -116,9 +117,11 @@ export class Tyria extends TyriaEventTarget { const dpr = window.devicePixelRatio || 1; const width = this.canvas.width / dpr; const height = this.canvas.height / dpr; + const padding = this.view.padding; + const translate = this.project(this.view.center); - const translateX = -translate[0] + (width / 2); - const translateY = -translate[1] + (height / 2); + const translateX = -translate[0] + (padding.left - padding.right + width) / 2; + const translateY = -translate[1] + (padding.top - padding.bottom + height) / 2; const transform = new DOMMatrix([dpr, 0, 0, dpr, translateX * dpr, translateY * dpr]); @@ -131,6 +134,7 @@ export class Tyria extends TyriaEventTarget { zoom: this.view.zoom, width, height, + padding, area: this.#getViewportArea(this.view), dpr, debug: this.debug, @@ -197,35 +201,35 @@ export class Tyria extends TyriaEventTarget { ctx.resetTransform(); // render padding - if(this.options.padding && this.debugLastViewOptions?.contain) { - ctx.fillStyle = '#673AB788'; - ctx.strokeStyle = '#673AB7'; - ctx.lineWidth = 2 * dpr; - - const padding = getPadding(this.options.padding); + ctx.fillStyle = '#673AB788'; + ctx.strokeStyle = '#673AB7'; + ctx.lineWidth = 2 * dpr; - if(padding.top) { - ctx.fillRect(padding.left * dpr, 0, (width - padding.left - padding.right) * dpr, padding.top * dpr); - } - if(padding.bottom) { - ctx.fillRect(padding.left * dpr, (height - padding.bottom) * dpr, (width - padding.left - padding.right) * dpr, height * dpr); - } - if(padding.left) { - ctx.fillRect(0, 0, padding.left * dpr, height * dpr); - } - if(padding.right) { - ctx.fillRect((width - padding.right) * dpr, 0, padding.right * dpr, height * dpr); - } - ctx.strokeRect(padding.left * dpr, padding.top * dpr, (width - padding.left - padding.right) * dpr, (height - padding.top - padding.bottom) * dpr); + if(padding.top) { + ctx.fillRect(padding.left * dpr, 0, (width - padding.left - padding.right) * dpr, padding.top * dpr); + } + if(padding.bottom) { + ctx.fillRect(padding.left * dpr, (height - padding.bottom) * dpr, (width - padding.left - padding.right) * dpr, height * dpr); + } + if(padding.left) { + ctx.fillRect(0, 0, padding.left * dpr, height * dpr); } + if(padding.right) { + ctx.fillRect((width - padding.right) * dpr, 0, padding.right * dpr, height * dpr); + } + ctx.strokeRect(padding.left * dpr, padding.top * dpr, (width - padding.left - padding.right) * dpr, (height - padding.top - padding.bottom) * dpr); // render map center - ctx.setTransform(dpr, 0, 0, dpr, dpr * width / 2, dpr * height / 2); + ctx.setTransform(dpr, 0, 0, dpr, dpr * (padding.left - padding.right + width) / 2, dpr * (padding.top - padding.bottom + height) / 2); ctx.fillStyle = 'lime'; ctx.fillRect(-4, -4, 8, 8); ctx.font = '12px monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; + ctx.fillStyle = '#000'; + ctx.fillText(`px ${translate[0]}, ${translate[1]}`, 8 + 1, 0 + 1); + ctx.fillText(`map ${this.view.center[0]}, ${this.view.center[1]}`, 8 + 1, 16 + 1); + ctx.fillText(`zoom ${this.view.zoom}`, 8 + 1, 32 + 1); ctx.fillStyle = '#fff'; ctx.fillText(`px ${translate[0]}, ${translate[1]}`, 8, 0); ctx.fillText(`map ${this.view.center[0]}, ${this.view.center[1]}`, 8, 16); @@ -264,6 +268,9 @@ export class Tyria extends TyriaEventTarget { // get dpr to correctly calculate viewport size const dpr = window.devicePixelRatio ?? 1; + // get padding + const padding = getPadding(view.padding ?? this.options.padding); + // make sure the area is completely visible in the viewport // TODO: handle passing contain + center? if(view.contain) { @@ -272,7 +279,6 @@ export class Tyria extends TyriaEventTarget { const aspectRatio = size[0] / size[1]; // get size and aspect ratio of the viewport - const padding = getPadding(this.options.padding); const viewportSizePx = [ (this.canvas.width / dpr) - padding.left - padding.right, (this.canvas.height / dpr) - padding.top - padding.bottom, @@ -293,7 +299,6 @@ export class Tyria extends TyriaEventTarget { } // set center to the middle of the area - // TODO: adjust for asymmetric padding center = add(view.contain[0], multiply(size, 0.5)); // make sure we are zooming out when zoom snapping @@ -353,17 +358,21 @@ export class Tyria extends TyriaEventTarget { center = this.unproject(centerPx, zoom); } - return { center, zoom }; + return { center, zoom, padding }; } /** Gets the area visible in the viewport */ - #getViewportArea(view: View): Bounds { + #getViewportArea(view: Readonly): Bounds { const dpr = window.devicePixelRatio ?? 1; - const viewportHalfSizePx: Point = [this.canvas.width / dpr / 2, this.canvas.height / dpr / 2]; + const padding = view.padding; + const viewportHalfSizePx: Point = [ + (padding.left - padding.right + this.canvas.width / dpr) / 2, + (padding.top - padding.bottom + this.canvas.height / dpr) / 2 + ]; const centerPx = this.project(view.center); - const topLeft = this.unproject(subtract(centerPx, viewportHalfSizePx)); - const bottomRight = this.unproject(add(centerPx, viewportHalfSizePx)); + const topLeft = this.unproject(subtract(subtract(centerPx, viewportHalfSizePx), 0)); + const bottomRight = this.unproject(add(add(centerPx, viewportHalfSizePx), [padding.right - padding.left, padding.bottom - padding.top])); return [topLeft, bottomRight]; } @@ -399,7 +408,7 @@ export class Tyria extends TyriaEventTarget { const target = this.resolveView(view); // if we are not moving, don't move - if(target.zoom === start.zoom && target.center[0] === start.center[0] && target.center[1] === start.center[1]) { + if(target.zoom === start.zoom && target.center[0] === start.center[0] && target.center[1] === start.center[1] && target.padding.top === start.padding.top && target.padding.right === start.padding.right && target.padding.bottom === start.padding.bottom && target.padding.left === start.padding.left) { return; } @@ -409,10 +418,10 @@ export class Tyria extends TyriaEventTarget { const startArea = this.#getViewportArea(start); const targetArea = this.#getViewportArea(target); const combinedArea: Bounds = [ - [Math.min(startArea[0][0], targetArea[0][0]), Math.min(startArea[0][1], targetArea[0][1])] as Point, - [Math.max(startArea[1][0], targetArea[1][0]), Math.max(startArea[1][1], targetArea[1][1])] as Point + [Math.min(startArea[0][0], targetArea[0][0]), Math.min(startArea[0][1], targetArea[0][1])], + [Math.max(startArea[1][0], targetArea[1][0]), Math.max(startArea[1][1], targetArea[1][1])] ]; - this.preload(this.resolveView({ contain: combinedArea })); + this.preload({ contain: combinedArea, padding: 0 }); // calculate delta const deltaZoom = target.zoom - start.zoom; @@ -439,8 +448,16 @@ export class Tyria extends TyriaEventTarget { // calculate center const center = add(start.center, multiply(deltaCenter, easedProgress * speedup)); + // calculate padding + const padding: Padding = { + top: start.padding.top + (target.padding.top - start.padding.top) * easedProgress, + right: start.padding.right + (target.padding.right - start.padding.right) * easedProgress, + bottom: start.padding.bottom + (target.padding.bottom - start.padding.bottom) * easedProgress, + left: start.padding.left + (target.padding.left - start.padding.left) * easedProgress, + } + // set view to the calculated center and zoom - this.view = { center, zoom }; + this.view = { center, zoom, padding }; if(progress === 1) { performance.mark('easeTo-end'); @@ -452,6 +469,7 @@ export class Tyria extends TyriaEventTarget { if(duration === 0) { // if the duration of the transition is 0 we just call the end frame + // TODO: why not just call `jumpTo(view)` at the start? frame(1); } else { // store current ease and queue frame @@ -498,11 +516,14 @@ export class Tyria extends TyriaEventTarget { /** Convert a pixel in the canvas (for example offsetX/offsetY from an event) to the corresponding map coordinates at that point */ canvasPixelToMapCoordinate([x, y]: Point) { const dpr = window.devicePixelRatio || 1; + const padding = this.view.padding; - const halfWidth = this.canvas.width / dpr / 2; - const halfHeight = this.canvas.height / dpr / 2; + const viewportHalfSizePx: Point = [ + (padding.left - padding.right + this.canvas.width / dpr) / 2, + (padding.top - padding.bottom + this.canvas.height / dpr) / 2 + ]; - const offset: Point = this.unproject([-x + halfWidth, -y + halfHeight]); + const offset: Point = this.unproject([-x + viewportHalfSizePx[0], -y + viewportHalfSizePx[1]]); return subtract(this.view.center, offset); } @@ -510,10 +531,11 @@ export class Tyria extends TyriaEventTarget { /** Convert a map coordinate to canvas px */ mapCoordinateToCanvasPixel(coordinate: Point) { const dpr = window.devicePixelRatio || 1; + const padding = this.view.padding; const viewportHalfSizePx: Point = [ - this.canvas.width / dpr / 2, - this.canvas.height / dpr / 2 + (padding.left - padding.right + this.canvas.width / dpr) / 2, + (padding.top - padding.bottom + this.canvas.height / dpr) / 2 ]; const pointPx = this.project(coordinate); @@ -543,6 +565,7 @@ export class Tyria extends TyriaEventTarget { zoom: target.zoom, width: this.canvas.width / dpr, height: this.canvas.height / dpr, + padding: target.padding, area: this.#getViewportArea(target), dpr: dpr, debug: this.debug, @@ -612,6 +635,7 @@ export class Tyria extends TyriaEventTarget { zoom: this.view.zoom, width, height, + padding: this.view.padding, area: this.#getViewportArea(this.view), dpr, debug: this.debug, diff --git a/packages/tyria/src/layer.ts b/packages/tyria/src/layer.ts index 48f8b1b..eb39b4a 100644 --- a/packages/tyria/src/layer.ts +++ b/packages/tyria/src/layer.ts @@ -1,6 +1,6 @@ import { ImageGetOptions } from "./image-manager"; import { RenderReason } from "./render-queue"; -import { Bounds, Point } from "./types"; +import { Bounds, Padding, Point } from "./types"; import { Tyria } from "./Tyria"; export interface Layer { @@ -23,6 +23,9 @@ export interface MapState { /** height of the map in px */ height: number, + /** padding of the map area */ + padding: Padding, + /** The visible area in the viewport in map coordinates */ area: Bounds, diff --git a/packages/tyria/src/layers/TileLayer.ts b/packages/tyria/src/layers/TileLayer.ts index bc86466..eddbe4e 100644 --- a/packages/tyria/src/layers/TileLayer.ts +++ b/packages/tyria/src/layers/TileLayer.ts @@ -23,7 +23,7 @@ export class TileLayer implements Layer { : new OffscreenCanvas(0, 0); } - getTiles({ state, project }) { + getTiles({ state, project }: Pick) { // get the zoom level of tiles to use (prefer higher resolution) const zoom = Math.ceil(state.zoom); @@ -46,12 +46,12 @@ export class TileLayer implements Layer { const boundsBottomRight = project(this.options.bounds?.[1] ?? [0, 0]); // get the top left position (px) - const topLeftX = Math.max(center[0] - state.width / 2, boundsTopLeft[0]); - const topLeftY = Math.max(center[1] - state.height / 2, boundsTopLeft[1]); + const topLeftX = Math.max(center[0] - (state.padding.left - state.padding.right + state.width) / 2, boundsTopLeft[0]); + const topLeftY = Math.max(center[1] - (state.padding.top - state.padding.bottom + state.height) / 2, boundsTopLeft[1]); // get the top right position (px) - const bottomRightX = Math.min(center[0] + state.width / 2, boundsBottomRight[0]) - 1; - const bottomRightY = Math.min(center[1] + state.height / 2, boundsBottomRight[1]) - 1; + const bottomRightX = Math.min(center[0] + (-state.padding.left + state.padding.right + state.width) / 2, boundsBottomRight[0]) - 1; + const bottomRightY = Math.min(center[1] + (-state.padding.top + state.padding.bottom + state.height) / 2, boundsBottomRight[1]) - 1; // convert px position to tiles const tileTopLeft: Point = [Math.floor(topLeftX / renderedTileSize), Math.floor(topLeftY / renderedTileSize)]; diff --git a/packages/tyria/src/types.ts b/packages/tyria/src/types.ts index 0ac0bd2..f09f66d 100644 --- a/packages/tyria/src/types.ts +++ b/packages/tyria/src/types.ts @@ -7,6 +7,9 @@ export type View = { /** The zoom level of the map */ zoom: number; + + /** The padding of the map */ + padding: Padding; } export type ViewOptions = { @@ -25,6 +28,9 @@ export type ViewOptions = { /** Makes sure the viewport is completely within this area. */ cover?: Bounds; + /** Override map padding */ + padding?: Partial | number; + /** * Modifies the center to align with device pixels so tiles stay sharp. * @defaultValue true