Skip to content
Open
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
17 changes: 17 additions & 0 deletions examples/get-started/pure-js/orthographic/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## Example: Use deck.gl with OrthographicView

Uses [Vite](https://vitejs.dev/) to bundle and serve files.

## Usage

To install dependencies:

```bash
npm install
# or
yarn
```

Commands:
* `npm start` is the development target, to serve the app and hot reload.
* `npm run build` is the production target, to create the final bundle and write to disk.
36 changes: 36 additions & 0 deletions examples/get-started/pure-js/orthographic/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/* global document */
import {Deck, OrthographicView} from '@deck.gl/core';
import {ScatterplotLayer} from '@deck.gl/layers';

const INITIAL_VIEW_STATE = {
target: [0, 0, 0],
zoom: 1
};

const data = [];
for (let x = -50; x <= 50; x += 5) {
for (let y = -50; y <= 50; y += 5) {
data.push({position: [x, y], color: [(x + 50) * 2.5, (y + 50) * 2.5, 128]});
}
}

new Deck({
parent: document.body,
views: new OrthographicView({
controller: {
dragRotate: true
}
}),
initialViewState: INITIAL_VIEW_STATE,
controller: true,
layers: [
new ScatterplotLayer({
id: 'scatterplot',
data,
getPosition: d => d.position,
getFillColor: d => d.color,
getRadius: 2,
radiusUnits: 'pixels'
})
]
});
17 changes: 17 additions & 0 deletions examples/get-started/pure-js/orthographic/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>deck.gl OrthographicView Example</title>
<style>
body {
margin: 0;
padding: 0;
overflow: hidden;
}
</style>
</head>
<body>
<script type="module" src="./app.js"></script>
</body>
</html>
18 changes: 18 additions & 0 deletions examples/get-started/pure-js/orthographic/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "deck.gl-example-orthographic",
"version": "0.0.0",
"private": true,
"license": "MIT",
"scripts": {
"start": "vite --open",
"start-local": "vite --config ../../../vite.config.local.mjs",
"build": "vite build"
},
"dependencies": {
"@deck.gl/core": "^9.0.0",
"@deck.gl/layers": "^9.0.0"
},
"devDependencies": {
"vite": "^4.0.0"
}
}
76 changes: 56 additions & 20 deletions modules/core/src/controllers/orthographic-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,25 +150,65 @@ export class OrthographicState extends ViewState<
});
}

/**
* Start rotating
*/
rotateStart(): OrthographicState {
return this._getUpdatedState({});
rotateStart({pos}: {pos: [number, number]}): OrthographicState {
const {width, height, rotationOrbit = 0} = this.getViewportProps();
const centerX = width / 2;
const centerY = height / 2;
const startAngle = Math.atan2(centerY - pos[1], pos[0] - centerX);

return this._getUpdatedState({
startRotatePos: pos,
startRotationOrbit: rotationOrbit,
startRotationX: startAngle
}) as OrthographicState;
}

/**
* Rotate
*/
rotate(): OrthographicState {
return this;
rotate({
pos,
deltaAngleX = 0
}: {
pos?: [number, number];
deltaAngleX?: number;
deltaAngleY?: number;
}): OrthographicState {
const {startRotatePos, startRotationOrbit, startRotationX} = this.getState();
const {width, height, rotationOrbit = 0} = this.getViewportProps();

if (!startRotatePos || startRotationOrbit === undefined || startRotationX === undefined) {
return this;
}

let newRotationOrbit: number;

if (pos) {
const centerX = width / 2;
const centerY = height / 2;
const currentAngle = Math.atan2(centerY - pos[1], pos[0] - centerX);
let deltaAngle = currentAngle - startRotationX;

if (deltaAngle > Math.PI) {
deltaAngle -= 2 * Math.PI;
} else if (deltaAngle < -Math.PI) {
deltaAngle += 2 * Math.PI;
}

const sensitivity = 1.5;
newRotationOrbit = startRotationOrbit - (deltaAngle * 180 * sensitivity) / Math.PI;
} else {
newRotationOrbit = rotationOrbit + deltaAngleX;
}

return this._getUpdatedState({
rotationOrbit: newRotationOrbit
}) as OrthographicState;
}

/**
* End rotating
*/
rotateEnd(): OrthographicState {
return this._getUpdatedState({});
return this._getUpdatedState({
startRotatePos: null,
startRotationOrbit: null,
startRotationX: null
}) as OrthographicState;
}

// shortest path between two view states
Expand Down Expand Up @@ -366,23 +406,19 @@ export class OrthographicState extends ViewState<

return props;
}

}

export default class OrthographicController extends Controller<OrthographicState> {
ControllerState = OrthographicState;
transition = {
transitionDuration: 300,
transitionInterpolator: new LinearInterpolator(['target', 'zoomX', 'zoomY'])
transitionInterpolator: new LinearInterpolator(['target', 'zoomX', 'zoomY', 'rotationOrbit'])
};
dragMode: 'pan' | 'rotate' = 'pan';

setProps(props: ControllerProps & OrthographicStateProps) {
Object.assign(props, normalizeZoom(props));
super.setProps(props);
}

_onPanRotate() {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please review the controller implementation pattern used in other controllers. The ControllerState class is where deck.gl implements view state mutations and internal state

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing this out. I see that OrbitState already has rotateStart(), rotate(), and rotateEnd() methods for drag rotation, as well as rotateLeft()/rotateRight() for programmatic rotation.

Since OrthographicState extends OrbitState, it inherits all these methods. However, the Ctrl+drag rotation I implemented calculates rotation based on the angle from viewport center (atan2-based), which differs from OrbitState.rotate() that uses linear delta scaling.

Should I:

  1. Adapt the rotation calculation to fit within OrthographicState.rotate() pattern, or
  2. Keep the current approach if the atan2-based rotation is preferred for 2D use cases?

I'm happy to refactor to match the established pattern.

// No rotation in orthographic view
return false;
}
}
17 changes: 14 additions & 3 deletions modules/core/src/viewports/orthographic-viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {pixelsToWorld} from '@math.gl/web-mercator';

import type {Padding} from './viewport';

const viewMatrix = new Matrix4().lookAt({eye: [0, 0, 1]});
const DEGREES_TO_RADIANS = Math.PI / 180;
const baseViewMatrix = new Matrix4().lookAt({eye: [0, 0, 1]});

function getProjectionMatrix({
width,
Expand Down Expand Up @@ -82,6 +83,8 @@ export type OrthographicViewportOptions = {
far?: number;
/** Whether to use top-left coordinates (`true`) or bottom-left coordinates (`false`). Default `true`. */
flipY?: boolean;
/** Rotation angle around the Z axis, in degrees. Default `0`. */
rotationOrbit?: number;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it called rotationObrit instead of something shorter like rotation?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used rotationOrbit to maintain consistency with OrbitController/OrbitState, which already uses rotationOrbit for Z-axis rotation (line 19, 51 in orbit-controller.ts). Since OrthographicState extends OrbitState, reusing the same property name seemed appropriate.

However, if a shorter name like rotation is preferred for OrthographicView specifically, I can add it as an alias. Let me know which approach you'd prefer.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am in favor of rotation as well. If the plumbing becomes too confusing, I'm supportive of breaking away from OrbitState and make this an independent class.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have created #10106 to support other features but hopefully it will help free this PR from OrbitController

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a quick merge, there is probably type errors now that OrthographicController defines its own internal state.

};

export default class OrthographicViewport extends Viewport {
Expand All @@ -91,6 +94,7 @@ export default class OrthographicViewport extends Viewport {
zoomX: number;
zoomY: number;
flipY: boolean;
rotationOrbit: number;

constructor(props: OrthographicViewportOptions) {
const {
Expand All @@ -101,7 +105,8 @@ export default class OrthographicViewport extends Viewport {
zoom = 0,
target = [0, 0, 0],
padding = null,
flipY = true
flipY = true,
rotationOrbit = 0
} = props;
const zoomX = props.zoomX ?? (Array.isArray(zoom) ? zoom[0] : zoom);
const zoomY = props.zoomY ?? (Array.isArray(zoom) ? zoom[1] : zoom);
Expand All @@ -119,13 +124,18 @@ export default class OrthographicViewport extends Viewport {
};
}

const viewMatrix = baseViewMatrix
.clone()
.rotateZ(-rotationOrbit * DEGREES_TO_RADIANS)
.scale([scale, scale * (flipY ? -1 : 1), scale]);

super({
...props,
// in case viewState contains longitude/latitude values,
// make sure that the base Viewport class does not treat this as a geospatial viewport
longitude: undefined,
position: target,
viewMatrix: viewMatrix.clone().scale([scale, scale * (flipY ? -1 : 1), scale]),
viewMatrix,
projectionMatrix: getProjectionMatrix({
width: width || 1,
height: height || 1,
Expand All @@ -141,6 +151,7 @@ export default class OrthographicViewport extends Viewport {
this.zoomX = zoomX;
this.zoomY = zoomY;
this.flipY = flipY;
this.rotationOrbit = rotationOrbit;
}

projectFlat([X, Y]: number[]): [number, number] {
Expand Down
2 changes: 2 additions & 0 deletions modules/core/src/views/orthographic-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export type OrthographicViewState = {
* @default Infinity
*/
maxZoom?: number;
/** Rotation angle around the Z axis, in degrees. Default `0`. */
rotationOrbit?: number;
/** The min zoom level along X axis.
* @default `minZoom`
*/
Expand Down
1 change: 1 addition & 0 deletions test/modules/core/viewports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// Copyright (c) vis.gl contributors

import './viewport.spec';
import './orthographic-viewport.spec';
import './globe-viewport.spec';
import './web-mercator-project-unproject.spec';
import './web-mercator-viewport.spec';
Expand Down
117 changes: 117 additions & 0 deletions test/modules/core/viewports/orthographic-viewport.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// deck.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

import test from 'tape-promise/tape';
import {OrthographicViewport} from '@deck.gl/core';

test('OrthographicViewport#imports', t => {
t.ok(OrthographicViewport, 'OrthographicViewport import ok');
t.end();
});

test('OrthographicViewport#constructor - default props', t => {
const viewport = new OrthographicViewport({
width: 800,
height: 600
});

t.ok(viewport instanceof OrthographicViewport, 'Created OrthographicViewport');
t.deepEqual(viewport.target, [0, 0, 0], 'default target');
t.is(viewport.zoomX, 0, 'default zoomX');
t.is(viewport.zoomY, 0, 'default zoomY');
t.is(viewport.flipY, true, 'default flipY');
t.is(viewport.rotationOrbit, 0, 'default rotationOrbit');
t.end();
});

test('OrthographicViewport#constructor - custom props', t => {
const viewport = new OrthographicViewport({
width: 800,
height: 600,
target: [100, 200, 0],
zoom: 2,
flipY: false,
rotationOrbit: 45
});

t.ok(viewport instanceof OrthographicViewport, 'Created OrthographicViewport');
t.deepEqual(viewport.target, [100, 200, 0], 'custom target');
t.is(viewport.zoomX, 2, 'custom zoomX');
t.is(viewport.zoomY, 2, 'custom zoomY');
t.is(viewport.flipY, false, 'custom flipY');
t.is(viewport.rotationOrbit, 45, 'custom rotationOrbit');
t.end();
});

test('OrthographicViewport#rotationOrbit - viewMatrix changes with rotation', t => {
const viewport0 = new OrthographicViewport({
width: 800,
height: 600,
rotationOrbit: 0
});

const viewport90 = new OrthographicViewport({
width: 800,
height: 600,
rotationOrbit: 90
});

const viewport180 = new OrthographicViewport({
width: 800,
height: 600,
rotationOrbit: 180
});

t.notDeepEqual(
viewport0.viewMatrix,
viewport90.viewMatrix,
'viewMatrix differs with 90 degree rotation'
);
t.notDeepEqual(
viewport0.viewMatrix,
viewport180.viewMatrix,
'viewMatrix differs with 180 degree rotation'
);
t.notDeepEqual(
viewport90.viewMatrix,
viewport180.viewMatrix,
'viewMatrix differs between 90 and 180 degree rotation'
);
t.end();
});

test('OrthographicViewport#rotationOrbit - project/unproject consistency', t => {
const viewport = new OrthographicViewport({
width: 800,
height: 600,
target: [0, 0, 0],
zoom: 0,
rotationOrbit: 45
});

const worldPos = [100, 100, 0];
const screenPos = viewport.project(worldPos);
const unprojected = viewport.unproject(screenPos);

t.ok(
Math.abs(unprojected[0] - worldPos[0]) < 0.001 &&
Math.abs(unprojected[1] - worldPos[1]) < 0.001,
'project/unproject roundtrip consistent with rotation'
);
t.end();
});

test('OrthographicViewport#rotationOrbit - independent zoom axes', t => {
const viewport = new OrthographicViewport({
width: 800,
height: 600,
zoom: [1, 2],
rotationOrbit: 30
});

t.is(viewport.zoomX, 1, 'independent zoomX');
t.is(viewport.zoomY, 2, 'independent zoomY');
t.is(viewport.rotationOrbit, 30, 'rotationOrbit with independent zoom');
t.end();
});