Skip to content

Commit

Permalink
Merge pull request #20 from UniStuttgart-VISUS/feature/van-Wijk-Nuij-…
Browse files Browse the repository at this point in the history
…transition

Feature: van Wijk and Nuij transition
  • Loading branch information
mfranke93 authored Sep 26, 2023
2 parents 3a5ec4e + 3375f0c commit d6b1dd6
Show file tree
Hide file tree
Showing 8 changed files with 183 additions and 10 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -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
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,27 @@ 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.

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
Expand Down
3 changes: 2 additions & 1 deletion demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ <h1>API Tests</h1>
<legend>Transition method</legend>

<select id="transition-method">
<option value="concatenated" selected>concatenated</option>
<option value="concatenated">concatenated</option>
<option value="linear">linear</option>
<option value="box">box</option>
<option value="triangle">triangle</option>
<option value="around world">around world</option>
<option value="van Wijk and Nuij" selected>van Wijk and Nuij (2003) (ρ=1.5)</option>
</select>

<button id="fetch-failed">Fetch failed</button>
Expand Down
29 changes: 23 additions & 6 deletions demo/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const {
createLinearZoomAndPanTransition,
createBoxTransition,
createTriangularTransition,
createVanWijkAndNuijTransition,
} = MapTransitionHelper;

const canvas = document.querySelector('canvas');
Expand Down Expand Up @@ -114,24 +115,32 @@ 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,
newYork2,
{
x: canvas.width,
y: canvas.height,
},
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;

Expand Down Expand Up @@ -176,6 +185,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}`);
}
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
127 changes: 127 additions & 0 deletions src/predefined-transitions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { geoMercator } from 'd3-geo';

import type { Point2D, ViewPoint } from './points';
import type { TransitionFunction } from './transition-function';
import {
Expand Down Expand Up @@ -119,3 +121,128 @@ 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.
* @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 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_);
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_ };
};
}

0 comments on commit d6b1dd6

Please sign in to comment.