├── .editorconfig ├── .gitignore ├── deakins.gif ├── dist └── index.js ├── lazy.gif ├── license ├── package.json ├── readme.md ├── rollup.config.js ├── src └── index.ts ├── test └── index.html └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_size = 2 6 | indent_style = tab 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.{json,yml,md}] 13 | indent_style = space 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist/* 3 | .DS_Store 4 | !dist/index.js -------------------------------------------------------------------------------- /deakins.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terkelg/deakins/620f5749932fe58f8eb7728f01f507062ae56622/deakins.gif -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | export class Deakins { 2 | constructor(context, options = {}) { 3 | this.distance = 1000; 4 | this.viewport = { 5 | left: 0, 6 | right: 0, 7 | top: 0, 8 | bottom: 0, 9 | width: 0, 10 | height: 0, 11 | scale: [1.0, 1.0] 12 | }; 13 | this.flipAspectRatio = false; 14 | this.canvasSize = [0, 0]; 15 | this.lookAtVector = [0, 0]; 16 | this.context = context; 17 | this.fieldOfView = options.fieldOfView || Math.PI / 4.0; 18 | this.margin = options.margin || { 19 | top: 0, 20 | right: 0, 21 | bottom: 0, 22 | left: 0 23 | }; 24 | this.flipAspectRatio = !!options.flipAspectRatio; 25 | this.resize(); 26 | this.addListeners(); 27 | } 28 | begin() { 29 | this.context.save(); 30 | this.applyScale(); 31 | this.applyTranslation(); 32 | } 33 | end() { 34 | this.context.restore(); 35 | } 36 | applyScale() { 37 | this.context.scale(this.viewport.scale[0], this.viewport.scale[1]); 38 | } 39 | applyTranslation() { 40 | this.context.translate(-this.viewport.left, -this.viewport.top); 41 | } 42 | updateViewport() { 43 | if (this.flipAspectRatio) { 44 | this.aspectRatio = this.canvasSize[1] / this.canvasSize[0]; 45 | this.viewport.height = this.distance * Math.tan(this.fieldOfView); 46 | this.viewport.width = this.viewport.height / this.aspectRatio; 47 | } 48 | else { 49 | this.aspectRatio = this.canvasSize[0] / this.canvasSize[1]; 50 | this.viewport.width = this.distance * Math.tan(this.fieldOfView); 51 | this.viewport.height = this.viewport.width / this.aspectRatio; 52 | } 53 | this.viewport.left = this.lookAtVector[0] - (this.viewport.width / 2); 54 | this.viewport.top = this.lookAtVector[1] - (this.viewport.height / 2); 55 | this.viewport.right = this.viewport.left + this.viewport.width; 56 | this.viewport.bottom = this.viewport.top + this.viewport.height; 57 | this.viewport.scale[0] = this.canvasSize[0] / this.viewport.width; 58 | this.viewport.scale[1] = this.canvasSize[0] / this.viewport.height; 59 | } 60 | zoomTo(z) { 61 | this.distance = z; 62 | this.updateViewport(); 63 | } 64 | lookAt([x, y], lazy = false) { 65 | if (lazy) { 66 | const pointScreenSpace = this.worldToScreen([x, y]); 67 | const left = this.canvasSize[0] * this.margin.left; 68 | const right = this.canvasSize[0] - (this.canvasSize[0] * this.margin.right); 69 | const top = this.canvasSize[0] * this.margin.top; 70 | const bottom = this.canvasSize[0] - (this.canvasSize[0] * this.margin.bottom); 71 | if (pointScreenSpace[0] < left) { 72 | x = x - this.viewport.width * (this.margin.left - 0.5); 73 | } 74 | else if (pointScreenSpace[0] > right) { 75 | x = x - this.viewport.width * (0.5 - this.margin.right); 76 | } 77 | if (pointScreenSpace[1] < top) { 78 | y = y - this.viewport.height * (this.margin.top - 0.5); 79 | } 80 | else if (pointScreenSpace[1] > bottom) { 81 | y = y - this.viewport.height * (0.5 - this.margin.bottom); 82 | } 83 | } 84 | this.lookAtVector[0] = x; 85 | this.lookAtVector[1] = y; 86 | this.updateViewport(); 87 | } 88 | screenToWorld(point) { 89 | const x = (point[0] / this.viewport.scale[0]) + this.viewport.left; 90 | const y = (point[1] / this.viewport.scale[1]) + this.viewport.top; 91 | return [x, y]; 92 | } 93 | worldToScreen(point) { 94 | const x = (point[0] - this.viewport.left) * (this.viewport.scale[0]); 95 | const y = (point[1] - this.viewport.top) * (this.viewport.scale[1]); 96 | return [x, y]; 97 | } 98 | resize() { 99 | this.canvasSize[0] = this.context.canvas.width; 100 | this.canvasSize[1] = this.context.canvas.height; 101 | this.updateViewport(); 102 | } 103 | addListeners() { 104 | window.addEventListener(`wheel`, e => { 105 | if (e.ctrlKey) { 106 | let zoomLevel = this.distance - (e.deltaY * 20); 107 | if (zoomLevel <= 1) { 108 | zoomLevel = 1; 109 | } 110 | this.zoomTo(zoomLevel); 111 | } 112 | else { 113 | const x = this.lookAtVector[0] + (e.deltaX * 2); 114 | const y = this.lookAtVector[1] + (e.deltaY * 2); 115 | this.lookAt([x, y]); 116 | } 117 | }); 118 | window.addEventListener(`keydown`, e => { 119 | if (e.key === 'r') { 120 | this.zoomTo(1000); 121 | this.lookAt([0, 0]); 122 | } 123 | }); 124 | } 125 | } 126 | ; 127 | -------------------------------------------------------------------------------- /lazy.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terkelg/deakins/620f5749932fe58f8eb7728f01f507062ae56622/lazy.gif -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Terkel Gjervig (terkel.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "deakins", 3 | "version": "1.0.0", 4 | "description": "Small Canvas 2D viewport management", 5 | "module": "dist/index.mjs", 6 | "main": "dist/deakins.umd.js", 7 | "types": "types/index.d.ts", 8 | "scripts": { 9 | "dev": "npx tsc --watch", 10 | "build": "npm run build-declarations && rollup -c", 11 | "build-declarations": "tsc -d", 12 | "prepublishOnly": "npm run build" 13 | }, 14 | "files": [ 15 | "dist", 16 | "types" 17 | ], 18 | "author": "Terkel Gjervig ", 19 | "repository": "terkelg/deakins", 20 | "license": "MIT", 21 | "devDependencies": { 22 | "typescript": "^3.9.5", 23 | "rollup": "^2.16.1", 24 | "rollup-plugin-sucrase": "^2.1.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 |
5 | 6 | # Deakins 7 | > Small Canvas 2D Camera 8 | 9 | Small and simple 2D viewport/camera management for Canvas. 10 | Named after the legendary English cinematographer [Roger Deakins](https://www.imdb.com/name/nm0005683/) known for Blade Runner, Fargo, No Country for Old Men, and The Shawshank Redemption. 11 | 12 | 13 | ## Install 14 | 15 | ``` 16 | $ npm install deakins 17 | ``` 18 | 19 | This module is delivered as: 20 | 21 | * **ES Module**: [`dist/index.mjs`](https://unpkg.com/deakins/dist/index.mjs) 22 | * **UMD**: [`dist/deakins.umd.js`](https://unpkg.com/deakins/dist/deakins.umd.js) 23 | 24 | ## Usage 25 | 26 | ```js 27 | import { Deakins } from 'deakins'; 28 | 29 | const canvas = document.createElement(`canvas`); 30 | const context = canvas.getContext(`2d`); 31 | 32 | const camera = new Deakins(context); 33 | const player = new Player(); 34 | 35 | function loop() { 36 | camera.begin(); 37 | 38 | // Look at point in space, 39 | camera.lookAt(player.position); 40 | 41 | // Zoom 42 | camera.zoomTo(500); 43 | 44 | camera.end(); 45 | requestAnimationFrame(loop); 46 | } 47 | 48 | loop(); 49 | ``` 50 | 51 | Moving the camera 52 | 53 | ```js 54 | camera.lookAt([x, y]); 55 | 56 | camera.zoomTo(z); 57 | ``` 58 | 59 | ## API 60 | 61 | ### Deakins(context, [options]) 62 | Initializes a new `Deakins` camera instance. 63 | 64 | #### context 65 | Type: `CanvasRenderingContext2D`
66 | 67 | #### options.fieldOfView 68 | Type: `number`
69 | Default: `1000` 70 | 71 | This value is used to imitate a FOV camera effect as you zoom. 72 | 73 | #### options.flipAspectRatio 74 | Type: `boolean`
75 | Default: `false` 76 | 77 | By default, everything scales based on the width of the canvas. When `true`, the aspect ratio is defined by the height instead. 78 | 79 | #### options.margin 80 | Type: `LookAtMargins`
81 | Default: `{top: 0, right: 0, bottom: 0, left: 0}` 82 | 83 | Margins for all sides defined in screen space. 84 | This is used if the `lazy` option in `lookAt` is `true`. If `true`, the camera only follows the look-at point when the point is inside the screen space margin. 85 | 86 | ### camera.lookAt(point, [lazy]) 87 | 88 | Move the centre of the viewport to the location specified by the point. 89 | 90 | Call this in the RAF loop to follow a player character, for example. 91 | 92 | ```js 93 | camera.moveTo([11, 8]); 94 | ``` 95 | 96 | #### point 97 | Type: `[number, number]`
98 | Default: `[0, 0]` 99 | 100 | #### lazy 101 | Type: `boolean`
102 | Default: `false` 103 | 104 | Enable lazy look-at. If true, the camera won't move before the look-at point is inside the margin defined in [`options.margin`](#optionsmargin). If false, the look-at point stays centered. Below is an illustration of a lazy camera. 105 | 106 | 107 | 108 | 109 | 110 | ### camera.zoomTo(zoom) 111 | 112 | Zoom to the specified distance from the 2D plane. 113 | 114 | ```js 115 | camera.zoomTo(500); 116 | ``` 117 | 118 | #### zoom 119 | Type: `number`
120 | Default: `1000` 121 | 122 | 123 | ### camera.begin() 124 | 125 | On each render pass, call `.begin()` before drawing to set the right transforms. 126 | 127 | Appropriate transformations will be applied to the context, and world coordinates can be used directly with all canvas context calls. 128 | 129 | ```js 130 | camera.begin(); 131 | 132 | // Draw stuff here 133 | 134 | camera.end(); 135 | ``` 136 | 137 | 138 | ### camera.end() 139 | 140 | Call `.end()` when you're done drawing to reset the context. 141 | 142 | 143 | ### camera.worldToScreen(point) 144 | 145 | #### point 146 | Type: `[number, number]`
147 | 148 | Transform a `point` from world coordinates into screen coordinates - useful for placing DOM elements over the scene. 149 | 150 | 151 | ### camera.screenToWorld() 152 | Returns: `[number, number]` 153 | 154 | Transform and return the world space coordinate of the current look-at point. 155 | 156 | Useful if you want to project clicks and other screen space coordinates into 2D world coordinates. 157 | 158 | ### camera.resize() 159 | 160 | Call this in your resize handler to make sure the viewport is updated. 161 | 162 | 163 | ## Credit 164 | 165 | TypeScript conversion based on [camera](https://github.com/robashton/camera) by Rob Ashton with minor tweaks. Added support for more options, lazy camera movements, and slightly modified API. 166 | 167 | 168 | ## License 169 | 170 | MIT © [Terkel Gjervig](https://terkel.com) 171 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import sucrase from 'rollup-plugin-sucrase'; 2 | import pkg from './package.json'; 3 | 4 | export default { 5 | input: 'src/index.ts', 6 | output: [ 7 | { file: pkg.main, format: 'umd', name: 'deakins' }, 8 | { file: pkg.module, format: 'esm' } 9 | ], 10 | plugins: [ 11 | sucrase({ 12 | transforms: ['typescript'] 13 | }) 14 | ] 15 | }; -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export class Deakins { 2 | distance = 1000; 3 | fieldOfView: number; 4 | context: CanvasRenderingContext2D; 5 | margin: LookAtMargins; 6 | viewport = { 7 | left: 0, 8 | right: 0, 9 | top: 0, 10 | bottom: 0, 11 | width: 0, 12 | height: 0, 13 | scale: [1.0, 1.0] 14 | }; 15 | aspectRatio!: number; 16 | flipAspectRatio = false; 17 | private canvasSize = [0, 0]; 18 | private lookAtVector = [0, 0]; 19 | 20 | constructor (context: CanvasRenderingContext2D, options: CameraSettings = {}) { 21 | this.context = context; 22 | this.fieldOfView = options.fieldOfView || Math.PI / 4.0; 23 | this.margin = options.margin || { 24 | top: 0, 25 | right: 0, 26 | bottom: 0, 27 | left: 0 28 | }; 29 | this.flipAspectRatio = !!options.flipAspectRatio; 30 | this.resize(); 31 | this.addListeners(); 32 | } 33 | 34 | begin () { 35 | this.context.save(); 36 | this.applyScale(); 37 | this.applyTranslation(); 38 | } 39 | 40 | end () { 41 | this.context.restore(); 42 | } 43 | 44 | private applyScale () { 45 | this.context.scale(this.viewport.scale[0], this.viewport.scale[1]); 46 | } 47 | 48 | private applyTranslation () { 49 | this.context.translate(-this.viewport.left, -this.viewport.top); 50 | } 51 | 52 | private updateViewport () { 53 | if (this.flipAspectRatio) { 54 | this.aspectRatio = this.canvasSize[1] / this.canvasSize[0]; 55 | this.viewport.height = this.distance * Math.tan(this.fieldOfView); 56 | this.viewport.width = this.viewport.height / this.aspectRatio; 57 | } else { 58 | this.aspectRatio = this.canvasSize[0] / this.canvasSize[1]; 59 | this.viewport.width = this.distance * Math.tan(this.fieldOfView); 60 | this.viewport.height = this.viewport.width / this.aspectRatio; 61 | } 62 | this.viewport.left = this.lookAtVector[0] - (this.viewport.width / 2); 63 | this.viewport.top = this.lookAtVector[1] - (this.viewport.height / 2); 64 | this.viewport.right = this.viewport.left + this.viewport.width; 65 | this.viewport.bottom = this.viewport.top + this.viewport.height; 66 | this.viewport.scale[0] = this.canvasSize[0] / this.viewport.width; 67 | this.viewport.scale[1] = this.canvasSize[0] / this.viewport.height; 68 | } 69 | 70 | zoomTo (z: number) { 71 | this.distance = z; 72 | this.updateViewport(); 73 | } 74 | 75 | lookAt ([x, y]: number[], lazy = false) { 76 | if (lazy) { 77 | const pointScreenSpace = this.worldToScreen([x, y]); 78 | const left = this.canvasSize[0] * this.margin.left; 79 | const right = this.canvasSize[0] - (this.canvasSize[0] * this.margin.right); 80 | const top = this.canvasSize[0] * this.margin.top; 81 | const bottom = this.canvasSize[0] - (this.canvasSize[0] * this.margin.bottom); 82 | 83 | if (pointScreenSpace[0] < left) { 84 | x = x - this.viewport.width * (this.margin.left - 0.5); 85 | } else if (pointScreenSpace[0] > right) { 86 | x = x - this.viewport.width * (0.5 - this.margin.right); 87 | } 88 | 89 | if (pointScreenSpace[1] < top) { 90 | y = y - this.viewport.height * (this.margin.top - 0.5); 91 | } else if (pointScreenSpace[1] > bottom) { 92 | y = y - this.viewport.height * (0.5 - this.margin.bottom); 93 | } 94 | } 95 | this.lookAtVector[0] = x; 96 | this.lookAtVector[1] = y; 97 | this.updateViewport(); 98 | } 99 | 100 | screenToWorld (point: number[]) { 101 | const x = (point[0] / this.viewport.scale[0]) + this.viewport.left; 102 | const y = (point[1] / this.viewport.scale[1]) + this.viewport.top; 103 | return [x, y]; 104 | } 105 | 106 | worldToScreen (point: number[]) { 107 | const x = (point[0] - this.viewport.left) * (this.viewport.scale[0]); 108 | const y = (point[1] - this.viewport.top) * (this.viewport.scale[1]); 109 | return [x, y]; 110 | } 111 | 112 | resize () { 113 | this.canvasSize[0] = this.context.canvas.width; 114 | this.canvasSize[1] = this.context.canvas.height; 115 | this.updateViewport(); 116 | } 117 | 118 | private addListeners () { 119 | window.addEventListener(`wheel`, e => { 120 | if (e.ctrlKey) { 121 | let zoomLevel = this.distance - (e.deltaY * 20); 122 | if (zoomLevel <= 1) { 123 | zoomLevel = 1; 124 | } 125 | this.zoomTo(zoomLevel); 126 | } else { 127 | const x = this.lookAtVector[0] + (e.deltaX * 2); 128 | const y = this.lookAtVector[1] + (e.deltaY * 2); 129 | this.lookAt([x, y]); 130 | } 131 | }); 132 | 133 | window.addEventListener(`keydown`, e => { 134 | if (e.key === 'r') { 135 | this.zoomTo(1000); 136 | this.lookAt([0, 0]); 137 | } 138 | }); 139 | } 140 | }; 141 | 142 | export type LookAtMargins = { 143 | top: number, 144 | right: number, 145 | bottom: number, 146 | left: number 147 | }; 148 | 149 | export type CameraSettings = { 150 | fieldOfView?: number; 151 | margin?: LookAtMargins; 152 | flipAspectRatio?: boolean; 153 | } -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Deakins Example 4 | 11 | 12 | 13 | 38 | 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*"], 3 | "exclude": ["node_modules"], 4 | "compilerOptions": { 5 | "target": "ES2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 6 | "module": "es2020" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 7 | "outDir": "dist" /* Redirect output structure to the directory. */, 8 | "strict": true /* Enable all strict type-checking options. */, 9 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 10 | "skipLibCheck": true /* Skip type checking of declaration files. */, 11 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 12 | } 13 | } 14 | --------------------------------------------------------------------------------