From 0b6fa3d72ab62e3e4100855b2db0f6b32d560eee Mon Sep 17 00:00:00 2001 From: Max Franke Date: Tue, 26 Sep 2023 13:34:41 +0200 Subject: [PATCH 1/4] add working implementation of van Wijk and Nuij transition --- demo/index.html | 1 + demo/main.js | 18 +++++ src/index.ts | 1 + src/predefined-transitions.ts | 142 ++++++++++++++++++++++++++++++++++ 4 files changed, 162 insertions(+) diff --git a/demo/index.html b/demo/index.html index 8a4cf0f..02ddcee 100644 --- a/demo/index.html +++ b/demo/index.html @@ -28,6 +28,7 @@

API Tests

+ diff --git a/demo/main.js b/demo/main.js index 3f34693..1fe59f3 100644 --- a/demo/main.js +++ b/demo/main.js @@ -11,6 +11,7 @@ const { createLinearZoomAndPanTransition, createBoxTransition, createTriangularTransition, + createVanWijkAndNuijTransition, } = MapTransitionHelper; const canvas = document.querySelector('canvas'); @@ -114,6 +115,15 @@ const aroundTheWorld = joinTransitions([ createLinearPanTransition({ lat: 0, lng: 120, zoom: 3 }, { lat: 0, lng: -120 }), createLinearPanTransition({ lat: 0, lng: -120, zoom: 3 }, { lat: 0, lng: 0 }), ]); +const vanWijkAndNuij = createVanWijkAndNuijTransition( + stuttgart1, + frankfurt1, + { + x: canvas.width, + y: canvas.height, + }, + 1.4, +); let transition; requestAnimationFrame(async (_) => { document.body.toggleAttribute('inert', true); @@ -176,6 +186,14 @@ methodSelect.addEventListener('input', (evt) => { tileMap, ); break; + case 'van Wijk and Nuij': + promise = preloadTransition( + vanWijkAndNuij, + { x: canvas.width, y: canvas.height }, + 300, + tileMap, + ); + break; default: throw new Error(`unknown transition method: ${evt.target.value}`); } diff --git a/src/index.ts b/src/index.ts index aad642c..6c64c09 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ export { createLinearZoomAndPanTransition, createPerceivedLinearZoomAndPanTransition, createTriangularTransition, + createVanWijkAndNuijTransition, } from './predefined-transitions'; export { default as preloadTransition } from './preload'; export { default as TileMap } from './tile-map'; diff --git a/src/predefined-transitions.ts b/src/predefined-transitions.ts index 964cb84..80a8757 100644 --- a/src/predefined-transitions.ts +++ b/src/predefined-transitions.ts @@ -1,3 +1,6 @@ +import { geoMercator } from 'd3-geo'; + +import { calculateFrame } from '.'; import type { Point2D, ViewPoint } from './points'; import type { TransitionFunction } from './transition-function'; import { @@ -119,3 +122,142 @@ export function createTriangularTransition( createPerceivedLinearZoomAndPanTransition(midViewpoint, p1), ]); } + +/** + * Create a transition following the hyperbolic path recommended by van Wijk + * and Nuij: "Smooth and efficient zooming and panning" (Proc. InfoVis, 2003). + * + * Note: As of now, this library only supports Mercator projection. This + * transition should be done in image space, even if the `TransitionFunction` + * outputs geographical space ViewPoints. For now, this function will assume + * Mercator projection is used for its internal calculations. This should be + * amended once different projections are supported. + * + * @param p0 Start point + * @param p1 End point + * @param canvasSize Size of the canvas the transition will run on + * @param rho (optional) Parameter ρ, which determines the shape of the + * hyperbolic path in (u, w) space (see the paper). + * If not passed, the recommended value of 1.4 is + * used. be calculated to fit. + * @returns f Hyperbolic transition from `p0` to `p1` + */ +export function createVanWijkAndNuijTransition( + p0: ViewPoint, + p1: ViewPoint, + canvasSize: Point2D, + rho = 1.4, +): TransitionFunction { + const mercator = geoMercator(); + if (!mercator.invert) throw new Error('Mercator projection cannot be inverted'); + + mercator.fitExtent( + [ + [0, 0], + [canvasSize.x, canvasSize.y], + ], + { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: {}, + geometry: { + type: 'LineString', + coordinates: [ + [p0.lng, p0.lat], + [p1.lng, p1.lat], + ], + }, + }, + ], + }, + ); + + const baseScale = mercator.scale(); + const zoomFactor0 = Math.pow(2, p0.zoom + 8) / (2 * Math.PI) / baseScale; + const zoomFactor1 = Math.pow(2, p1.zoom + 8) / (2 * Math.PI) / baseScale; + + mercator.translate([0, 0]).scale(1); + + const xy0 = mercator([p0.lng, p0.lat]); + const xy1 = mercator([p1.lng, p1.lat]); + if (xy0 === null || xy1 === null) throw new Error('cannot project start or end point'); + + const [x0, y0] = xy0; + const [x1, y1] = xy1; + + const u0 = 0; + const u1 = Math.sqrt(Math.pow(x0 - x1, 2) + Math.pow(y0 - y1, 2)); + + // w0, w1 are dependent on distance between p0 and p1 + const w0 = (1 / zoomFactor0) * u1; + const w1 = (1 / zoomFactor1) * u1; + + const movementDirection = Math.atan2(y1 - y0, x1 - x0); + const factorX = Math.cos(movementDirection); + const factorY = Math.sin(movementDirection); + + const canvasWidth = canvasSize.x; + const f0 = calculateFrame(canvasSize, p0); + const f1 = calculateFrame(canvasSize, p1); + const borderPosition0 = f0.borderPosition(p1); + const borderPosition1 = f1.borderPosition(p0); + + // u1 is calculated assuming a motion over the total horizontal width. Zoom + // out further if the motion is along the shorter side. + const motionDiagonal = Math.sqrt( + Math.pow(borderPosition0.x - borderPosition1.x, 2) + + Math.pow(borderPosition0.y - borderPosition1.y, 2), + ); + const motionScale = canvasWidth / motionDiagonal; + + const b0 = (w1 ** 2 - w0 ** 2 + rho ** 4 * (u1 - u0) ** 2) / (2 * w0 * rho ** 2 * (u1 - u0)); + const b1 = (w1 ** 2 - w0 ** 2 + -1 * rho ** 4 * (u1 - u0) ** 2) / (2 * w1 * rho ** 2 * (u1 - u0)); + + const r0 = Math.log(-b0 + Math.sqrt(b0 ** 2 + 1)); + const r1 = Math.log(-b1 + Math.sqrt(b1 ** 2 + 1)); + + const S = (r1 - r0) / rho; + + const u = (s: number): number => { + return ( + (w0 / rho ** 2) * Math.cosh(r0) * Math.tanh(rho * s + r0) - + (w0 / rho ** 2) * Math.sinh(r0) + + u0 + ); + }; + + const w = (s: number): number => { + return (w0 * Math.cosh(r0)) / Math.cosh(rho * s + r0); + }; + + const position = (t: number): Point2D => { + const s = t * S; + const u_ = u(s); + return { + x: x0 + factorX * u_, + y: y0 + factorY * u_, + }; + }; + + const zoom = (t: number): number => { + const s = t * S; + const w_ = w(s); + const wNorm = Math.log2(w0 / (w_ * motionScale)); + const zoomLevel = p0.zoom + wNorm; + + return zoomLevel; + }; + + return (t: number): ViewPoint => { + const { x, y } = position(t); + const zoom_ = zoom(t); + + const pos = mercator.invert?.([x, y]) ?? null; + if (pos === null) throw new Error(`cannot invert coordinates [${x}, ${y}]`); + + const [lng, lat] = pos; + return { lng, lat, zoom: zoom_ }; + }; +} From b1be2178c19bd6789f60eb34dc09a1d5d091f705 Mon Sep 17 00:00:00 2001 From: Max Franke Date: Tue, 26 Sep 2023 17:48:01 +0200 Subject: [PATCH 2/4] add documentation, make default in demo --- README.md | 30 ++++++++++++++++++++++++++++++ demo/index.html | 4 ++-- demo/main.js | 15 +++++++-------- src/predefined-transitions.ts | 19 ++----------------- 4 files changed, 41 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 7bf32fd..e985301 100644 --- a/README.md +++ b/README.md @@ -318,6 +318,36 @@ Then, continue panning and zooming in again until centered on `p1`. | `minZoom` | `number` (optional) | Zoom level for panning phase. If not given, will be calculated to fit. | +##### `createVanWijkAndNuijTransition(p0: ViewPoint, p1: ViewPoint, canvasSize: Point2D, rho: number = 1.4): TransitionFunction` + +Create a transition following the hyperbolic path recommended by van Wijk and Nuij: *"Smooth and efficient zooming and panning"* (Proc. InfoVis, 2003). + +**Note:** As of now, this library only supports Mercator projection. +This transition should be done in image space, even if the `TransitionFunction` outputs geographical space ViewPoints. +For now, this function will assume Mercator projection is used for its internal calculations. +This should be amended once different projections are supported. + * + * @param p0 Start point + * @param p1 End point + * @param canvasSize Size of the canvas the transition will run on + * @param rho (optional) Parameter ρ, which determines the shape of the + * hyperbolic path in (u, w) space (see the paper). + * If not passed, the recommended value of 1.4 is + * used. be calculated to fit. + * @returns f Hyperbolic transition from `p0` to `p1` + +Create a (perceived) triangular transition: +Zoom out and pan from `p0` to the midpoint between `p0` and `p1` in the image space, at which time both `p0` and `p1` will be visible. +Then, continue panning and zooming in again until centered on `p1`. + +| Parameter | Type | Description | +| --- | --- | --- | +| `p0` | `ViewPoint` | Start point | +| `p1` | `ViewPoint` | End point | +| `canvasSize` | `Point2D` | Size of the canvas the transition will run on | +| `rho` | `number` (optional) | Parameter ρ, which determines the shape of the hyperbolic path in (u, w) space (see the paper). If not passed, the recommended value of 1.4 is used. | + + ### `preloadTransition` Function ``` typescript diff --git a/demo/index.html b/demo/index.html index 02ddcee..872b09b 100644 --- a/demo/index.html +++ b/demo/index.html @@ -23,12 +23,12 @@

API Tests

Transition method diff --git a/demo/main.js b/demo/main.js index 1fe59f3..07f1b7e 100644 --- a/demo/main.js +++ b/demo/main.js @@ -117,31 +117,30 @@ const aroundTheWorld = joinTransitions([ ]); const vanWijkAndNuij = createVanWijkAndNuijTransition( stuttgart1, - frankfurt1, + newYork2, { x: canvas.width, y: canvas.height, }, - 1.4, + 1.5, ); let transition; requestAnimationFrame(async (_) => { document.body.toggleAttribute('inert', true); transition = await preloadTransition( - linearTransition, + vanWijkAndNuij, { x: canvas.width, y: canvas.height }, - 180, + 300, tileMap, ); registerEventListeners(transition); + requestAnimationFrame(() => { + document.querySelector('#initialize')?.click(); + }); document.body.toggleAttribute('inert', false); }); const methodSelect = document.querySelector('#transition-method'); -requestAnimationFrame((_) => { - methodSelect.value = 'linear'; -}); - methodSelect.addEventListener('input', (evt) => { let promise; diff --git a/src/predefined-transitions.ts b/src/predefined-transitions.ts index 80a8757..c902ef9 100644 --- a/src/predefined-transitions.ts +++ b/src/predefined-transitions.ts @@ -1,6 +1,5 @@ import { geoMercator } from 'd3-geo'; -import { calculateFrame } from '.'; import type { Point2D, ViewPoint } from './points'; import type { TransitionFunction } from './transition-function'; import { @@ -139,7 +138,7 @@ export function createTriangularTransition( * @param rho (optional) Parameter ρ, which determines the shape of the * hyperbolic path in (u, w) space (see the paper). * If not passed, the recommended value of 1.4 is - * used. be calculated to fit. + * used. * @returns f Hyperbolic transition from `p0` to `p1` */ export function createVanWijkAndNuijTransition( @@ -198,20 +197,6 @@ export function createVanWijkAndNuijTransition( const factorX = Math.cos(movementDirection); const factorY = Math.sin(movementDirection); - const canvasWidth = canvasSize.x; - const f0 = calculateFrame(canvasSize, p0); - const f1 = calculateFrame(canvasSize, p1); - const borderPosition0 = f0.borderPosition(p1); - const borderPosition1 = f1.borderPosition(p0); - - // u1 is calculated assuming a motion over the total horizontal width. Zoom - // out further if the motion is along the shorter side. - const motionDiagonal = Math.sqrt( - Math.pow(borderPosition0.x - borderPosition1.x, 2) + - Math.pow(borderPosition0.y - borderPosition1.y, 2), - ); - const motionScale = canvasWidth / motionDiagonal; - const b0 = (w1 ** 2 - w0 ** 2 + rho ** 4 * (u1 - u0) ** 2) / (2 * w0 * rho ** 2 * (u1 - u0)); const b1 = (w1 ** 2 - w0 ** 2 + -1 * rho ** 4 * (u1 - u0) ** 2) / (2 * w1 * rho ** 2 * (u1 - u0)); @@ -244,7 +229,7 @@ export function createVanWijkAndNuijTransition( const zoom = (t: number): number => { const s = t * S; const w_ = w(s); - const wNorm = Math.log2(w0 / (w_ * motionScale)); + const wNorm = Math.log2(w0 / w_); const zoomLevel = p0.zoom + wNorm; return zoomLevel; From 52603af499ddd31623c90338c769726b534c8d3a Mon Sep 17 00:00:00 2001 From: Max Franke Date: Tue, 26 Sep 2023 17:51:09 +0200 Subject: [PATCH 3/4] Update README.md --- README.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/README.md b/README.md index e985301..92799f6 100644 --- a/README.md +++ b/README.md @@ -326,15 +326,6 @@ Create a transition following the hyperbolic path recommended by van Wijk and Nu This transition should be done in image space, even if the `TransitionFunction` outputs geographical space ViewPoints. For now, this function will assume Mercator projection is used for its internal calculations. This should be amended once different projections are supported. - * - * @param p0 Start point - * @param p1 End point - * @param canvasSize Size of the canvas the transition will run on - * @param rho (optional) Parameter ρ, which determines the shape of the - * hyperbolic path in (u, w) space (see the paper). - * If not passed, the recommended value of 1.4 is - * used. be calculated to fit. - * @returns f Hyperbolic transition from `p0` to `p1` Create a (perceived) triangular transition: Zoom out and pan from `p0` to the midpoint between `p0` and `p1` in the image space, at which time both `p0` and `p1` will be visible. From 3375f0ccf5ded608652df3cfb78037b6e752eb3a Mon Sep 17 00:00:00 2001 From: Max Franke Date: Tue, 26 Sep 2023 17:54:14 +0200 Subject: [PATCH 4/4] update CHANGELOG, bump version --- CHANGELOG | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 18e381a..788a619 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,12 @@ Release Notes ------------- +2023-09-26 v0.5.0 + +Added the hyperbolic transition path suggested by van Wijk and Nuij +(2003) to the pre-defined transitions. + + 2023-03-17 v0.4.4 Add an optional offset to the visibility calculation of coordinates in a diff --git a/package-lock.json b/package-lock.json index c8520c6..ff4b68e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "map-transition-helper", - "version": "0.4.4", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "map-transition-helper", - "version": "0.4.4", + "version": "0.5.0", "license": "MIT", "dependencies": { "d3-array": "^3.2.1", diff --git a/package.json b/package.json index 6751f30..2dfdfe3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "map-transition-helper", - "version": "0.4.4", + "version": "0.5.0", "description": "A library to compose smooth map transition animations. The required WebMercator tiles are then pre-loaded, and the transition is rendered in a Canvas.", "browser": "lib/map-transition-helper.umd.js", "module": "lib/map-transition-helper.esm.js",