├── .eslintignore ├── example ├── js │ └── draw-dev.js └── exampleShapeBuilder.html ├── scripts └── clean.sh ├── .prettierrc ├── test ├── engine │ ├── draw-engine.unit.spec.js │ └── shape-builder.unit.spec.js ├── core │ ├── mat2d.unit.spec.js │ ├── vec2d.unit.spec.js │ ├── point2d.unit.spec.js │ └── aabox2d.unit.spec.js └── draw.unit.spec.js ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ └── build.yml ├── CONTRIBUTOR_LICENSE_AGREEMENT.md └── CONTRIBUTING.md ├── sonar-project.properties ├── src ├── util │ ├── utils.js │ ├── aggregation.js │ ├── canvas-utils.js │ └── event-handler.js ├── core │ ├── configure.js │ ├── mat2.js │ ├── vec2d.js │ ├── mat2d.js │ ├── point2d.js │ └── aabox2d.js ├── draw.js ├── style │ ├── basic-style.js │ ├── fill-style.js │ └── color-rgba.js ├── shapes │ ├── poly.js │ ├── point.js │ ├── circle.js │ ├── rect.js │ ├── base-shape.js │ └── poly-line.js ├── math │ ├── math.js │ └── convex-hull.js ├── interactions │ ├── vert-editable-shape.js │ ├── interact-utils.js │ └── xform-shape.js └── view │ └── camera2d.js ├── LICENSE ├── .gitignore ├── .travis.yml ├── README.md ├── webpack.dev.config.js ├── webpack.config.js ├── .babelrc ├── package.json └── .eslintrc.js /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*.spec.js 2 | -------------------------------------------------------------------------------- /example/js/draw-dev.js: -------------------------------------------------------------------------------- 1 | ../../dist/mapd-draw-dev.js -------------------------------------------------------------------------------- /scripts/clean.sh: -------------------------------------------------------------------------------- 1 | rm -rf build.log error.log dist/* 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "flow", 3 | "semi": false 4 | } -------------------------------------------------------------------------------- /test/engine/draw-engine.unit.spec.js: -------------------------------------------------------------------------------- 1 | import chai, {expect} from 'chai' 2 | import DrawEngine from '../../src/engine/draw-engine.js' 3 | 4 | describe("Draw Engine", () => { 5 | it("should be a function", () => { 6 | expect(typeof DrawEngine).to.equal("function") 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /test/engine/shape-builder.unit.spec.js: -------------------------------------------------------------------------------- 1 | import chai, {expect} from 'chai' 2 | import ShapeBuilder from '../../src/engine/shape-builder.js' 3 | 4 | describe("Shape Builder", () => { 5 | it("should be a function", () => { 6 | expect(typeof ShapeBuilder).to.equal("function") 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /test/core/mat2d.unit.spec.js: -------------------------------------------------------------------------------- 1 | import chai, {expect} from 'chai' 2 | import Mat2d from '../../src/core/mat2d.js' 3 | 4 | describe("Mat2D", () => { 5 | describe("singular value decomposition", () => { 6 | it("should be a function", () => { 7 | expect(typeof Mat2d.svd).to.equal("function") 8 | }) 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## :beetle: If bug 2 | - [ ] Operating System and version: 3 | - [ ] Browser and version: 4 | - [ ] Steps to reproduce (including dashboard link): 5 | - [ ] Description of issue: 6 | 7 | ## :new: If feature request 8 | - [ ] User story: 9 | - [ ] Specific requirements/functionality: 10 | - [ ] Mocks (if applicable): 11 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Merge Checklist 2 | ## :wrench: Issue(s) fixed: 3 | - [ ] Author referenced issue(s) fixed by this PR: 4 | - [ ] Fixes #0 5 | 6 | ## :smoking: Smoke Test 7 | - [ ] Works in chrome 8 | - [ ] Works in firefox 9 | - [ ] Works in safari 10 | - [ ] Works in ie edge 11 | - [ ] Works in ie 11 12 | 13 | ## :ship: Merge 14 | - [ ] author crafted PR's title into release-worthy commit message. 15 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=mapd-draw 2 | sonar.organization=omnisci 3 | 4 | # This is the name and version displayed in the SonarCloud UI. 5 | #sonar.projectName=mapd-draw 6 | #sonar.projectVersion=1.0 7 | 8 | # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. 9 | #sonar.sources=. 10 | 11 | # Encoding of the source code. Default is default system encoding 12 | #sonar.sourceEncoding=UTF-8 13 | -------------------------------------------------------------------------------- /src/util/utils.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | /** 4 | * Binds a this arg to a list of different function names. 5 | * This is most widely used to bind a this to member functions 6 | * that are used as callbacks in some fashion 7 | * @param {string[]} funcNames array of member function names that are part of thisArg 8 | * @param {Object} thisArg object to bind 9 | */ 10 | export function bindAll(funcNames, thisArg) { 11 | funcNames.forEach(funcName => { 12 | if (!thisArg[funcName]) { 13 | return 14 | } 15 | thisArg[funcName] = thisArg[funcName].bind(thisArg) 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 HEAVY.AI, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /test/draw.unit.spec.js: -------------------------------------------------------------------------------- 1 | import chai, {expect} from 'chai' 2 | import * as Draw from '../src/draw.js' 3 | 4 | describe("HEAVY.AI Draw", () => { 5 | it("should export all drawing functionality", () => { 6 | expect(Draw.AABox2d 7 | && Draw.DrawEngine 8 | && Draw.ShapeBuilder 9 | && Draw.Mat2d 10 | && Draw.Point2d 11 | && Draw.Mat2 12 | && Draw.BasicStyle 13 | && Draw.Vec2d 14 | && Draw.Circle 15 | && Draw.Rect 16 | && Draw.Poly 17 | && Draw.PolyLine 18 | && Draw.Point 19 | && Draw.Math 20 | && Draw.simpleHull_2D 21 | ).to.exist 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: 5 | - master 6 | # pull_request: 7 | # types: [opened, synchronize, reopened] 8 | jobs: 9 | sonarcloud: 10 | name: SonarCloud 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 16 | - name: SonarCloud Scan 17 | uses: SonarSource/sonarcloud-github-action@master 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any 20 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # tmp files 4 | *.log 5 | *~ 6 | 7 | # local files 8 | node_modules 9 | bower_components 10 | 11 | # ide files 12 | *.iml 13 | .idea 14 | .project 15 | .vscode 16 | 17 | # vim files 18 | .*.swp 19 | 20 | # mac 21 | .DS_Store 22 | 23 | # ctag 24 | .tags 25 | 26 | # files for gh-pages and grunt-contrib-jasmine 27 | .grunt 28 | 29 | # jasmine spec runners 30 | spec/*.html 31 | 32 | # various IDE support files 33 | .jsbeautifyrc 34 | 35 | # coverage reports 36 | coverage 37 | 38 | # browserify bundle & test 39 | bundle.js 40 | spec/index-browserify.html 41 | 42 | # font noise from some doc tool 43 | web/docs/public 44 | 45 | # generated files 46 | # dist/ 47 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '6' 4 | notifications: 5 | slack: 6 | secure: sfXiUs4yYzMe4YzyHbzyTslVKo4MaXraeUMVCk31RcqCRD1pZ1ZhhSYReEbiQyBM9JA+gQ5exQE7W3q7c9ycJULWv6E3jEd2DIXQ/vh9zq1KoggrHWH2jBVIXtGmCk2mh1k/FomB2DENjT3nQ4EvGWJ4dWaVPIy1vRPYhKeCaPYBQsQrkT0597ev9XBjPdNgI9LUiLj9ABq5rKSbT1PSbThfj3XZLR9iL1mqjvwAoaqxGj0ZhXoJQW5oSxW7jWW/HnjW338FlzHSRJhMLnIFMq2IqkLcQFuQIM/PREmDT16BQMj+zmOAIyEwcVZ3vJQdlukWZWT9HzckmXKtFbmJfq1RIHY5xpZgqm6MvMg/m8E3Dg6Mirx7ZWP3dnRNvvnT3kzdqIL48KgCiERfaZhc5mclNspMCmJV5GRUI/MfnhEbvKK+bHmFCKTRFI0a0UI3qntNnFyqdt3TdEMyp4+FP1vd5qhA07vpUkcCxcSipaA1B9xWeGXyzUp7n0soVrcVrJ+lXuxMMs1WyhjT+b4vedil6VvADPQYoIZZZ/mwWfJdA6rMBuaWEbNEFoh1MfiRDEUp3RLoeyYKDCRTBagSe+MFXxPsj5F5D1QMA+7uOLptj75cxB19zSu7npBO965haA/woYoAwWffRAP/h+oaJr6bdErV2dHv5zg8b5e6oKU= 7 | email: false 8 | -------------------------------------------------------------------------------- /test/core/vec2d.unit.spec.js: -------------------------------------------------------------------------------- 1 | import chai, {expect} from 'chai' 2 | import Vec2d from '../../src/core/vec2d.js' 3 | 4 | describe("Vec2D", () => { 5 | describe("cross2d", () => { 6 | it("should be a function", () => { 7 | expect(typeof Vec2d.cross2d).to.equal("function") 8 | }) 9 | }) 10 | 11 | describe("angleFast", () => { 12 | it("should be a function", () => { 13 | expect(typeof Vec2d.angleFast).to.equal("function") 14 | }) 15 | }) 16 | 17 | describe("angle", () => { 18 | it("should be a function", () => { 19 | expect(typeof Vec2d.angle).to.equal("function") 20 | }) 21 | }) 22 | 23 | describe("anglePosX", () => { 24 | it("should be a function", () => { 25 | expect(typeof Vec2d.anglePosX).to.equal("function") 26 | }) 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /src/core/configure.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Common utilities, overwriting some functionality from gl-matrix::common.js 3 | */ 4 | 5 | import { glMatrix } from "gl-matrix" 6 | 7 | /** 8 | * global difference epsilon for floating-pt comparisons 9 | */ 10 | 11 | export let EPSILON = glMatrix.EPSILON 12 | 13 | /** 14 | * Sets the type of array used when creating new vectors and matrices 15 | * 16 | * @param {Float32ArrayConstructor | ArrayConstructor} type Array type, such as Float32Array or Array 17 | */ 18 | export function setMatrixArrayType(type) { 19 | return glMatrix.setMatrixArrayType(type) 20 | } 21 | 22 | /** 23 | * Sets the global differencing epsilon for floating-pt comparisons 24 | * 25 | * @param {number} epsilon represents the difference between 1 and the smallest floating point number greater than 1. 26 | */ 27 | export function setEpsilon(epsilon) { 28 | EPSILON = epsilon 29 | } 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @heavyai/draw 2 | 3 | ### Table of Contents 4 | - [Quick Start](#quick-start) 5 | - [Synopsis](#synopsis) 6 | - [Installation](#installation) 7 | - [Testing and Linting](#testing-and-linting) 8 | - [Contributing](.github/CONTRIBUTING.md) 9 | - [License](LICENSE) 10 | 11 | # Quick Start 12 | ```bash 13 | npm run clean 14 | npm install 15 | npm run build 16 | npm test 17 | ``` 18 | 19 | # Synopsis 20 | 2d shape drawing and interaction library using an HTML canvas 2d rendering context. Basis for lasso tool in HeavyImmerse. 21 | 22 | # Installation 23 | ```bash 24 | npm install 25 | npm run build 26 | ``` 27 | 28 | # Testing and Linting 29 | 30 | Contributions to the HEAVY.AI Draw library should be unit-tested and linted. 31 | 32 | The linter can be invoked manually with 33 | 34 | ``` 35 | npm run lint 36 | ``` 37 | 38 | You can find our test suite in `/test`. Our tests are run with 39 | ``` 40 | npm test 41 | ``` 42 | -------------------------------------------------------------------------------- /webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require("webpack"); 2 | var path = require("path"); 3 | 4 | module.exports = { 5 | context: __dirname + "/src", 6 | entry: { 7 | "draw": "./draw.js" 8 | }, 9 | output: { 10 | path: __dirname + "/dist", 11 | filename: "[name]-dev.js", 12 | sourceMapFilename: "[name].dev.js.map", 13 | libraryTarget: "umd", 14 | library: "Draw" 15 | }, 16 | externals: { 17 | }, 18 | module: { 19 | loaders: [ 20 | { 21 | test: /\.js?$/, 22 | exclude: /node_modules/, 23 | loader: "babel-loader" 24 | }, 25 | { 26 | test: /\.json?$/, 27 | exclude: /node_modules/, 28 | loader: "json-loader" 29 | } 30 | ] 31 | }, 32 | plugins: [ 33 | new webpack.DefinePlugin({ 34 | "process.env": { 35 | NODE_ENV: JSON.stringify("production") 36 | } 37 | }) 38 | ], 39 | devtool: "eval-source-map", 40 | resolve: { 41 | extensions: [".js"] 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require("webpack"); 2 | var path = require("path"); 3 | 4 | module.exports = { 5 | context: __dirname + "/src", 6 | entry: { 7 | "draw": "./draw.js" 8 | }, 9 | output: { 10 | path: __dirname + "/dist", 11 | filename: "[name].js", 12 | sourceMapFilename: "[name].js.map", 13 | libraryTarget: "umd", 14 | library: "Draw" 15 | }, 16 | externals: { 17 | }, 18 | module: { 19 | loaders: [ 20 | { 21 | test: /\.js?$/, 22 | exclude: /node_modules/, 23 | loader: "babel-loader" 24 | }, 25 | { 26 | test: /\.json?$/, 27 | exclude: /node_modules/, 28 | loader: "json-loader" 29 | } 30 | ] 31 | }, 32 | plugins: [ 33 | new webpack.DefinePlugin({ 34 | "process.env": { 35 | NODE_ENV: JSON.stringify("production") 36 | } 37 | }), 38 | new webpack.optimize.UglifyJsPlugin(), 39 | ], 40 | devtool: "nosources-source-map", 41 | resolve: { 42 | extensions: [".js"] 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ], 5 | "plugins": [ 6 | "@babel/plugin-syntax-dynamic-import", 7 | "@babel/plugin-syntax-import-meta", 8 | "@babel/plugin-proposal-class-properties", 9 | "@babel/plugin-proposal-json-strings", 10 | [ 11 | "@babel/plugin-proposal-decorators", 12 | { 13 | "legacy": true 14 | } 15 | ], 16 | "@babel/plugin-proposal-function-sent", 17 | "@babel/plugin-proposal-export-namespace-from", 18 | "@babel/plugin-proposal-numeric-separator", 19 | "@babel/plugin-proposal-throw-expressions", 20 | "@babel/plugin-proposal-export-default-from", 21 | "@babel/plugin-proposal-logical-assignment-operators", 22 | "@babel/plugin-proposal-optional-chaining", 23 | [ 24 | "@babel/plugin-proposal-pipeline-operator", 25 | { 26 | "proposal": "minimal" 27 | } 28 | ], 29 | "@babel/plugin-proposal-nullish-coalescing-operator", 30 | "@babel/plugin-proposal-do-expressions", 31 | "@babel/plugin-proposal-function-bind" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /src/draw.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | export { version } from "../package.json" 4 | 5 | import * as Configure from "./core/configure" 6 | export { Configure } 7 | import ShapeBuilder from "./engine/shape-builder" 8 | export { ShapeBuilder } 9 | import DrawEngine from "./engine/draw-engine" 10 | export { DrawEngine } 11 | import * as AABox2d from "./core/aabox2d" 12 | export { AABox2d } 13 | import Mat2d from "./core/mat2d" 14 | export { Mat2d } 15 | import * as Point2d from "./core/point2d" 16 | export { Point2d } 17 | import Mat2 from "./core/mat2" 18 | export { Mat2 } 19 | import BasicStyle from "./style/basic-style" 20 | export { BasicStyle } 21 | import Vec2d from "./core/vec2d" 22 | export { Vec2d } 23 | import Circle from "./shapes/circle" 24 | export { Circle } 25 | import Rect from "./shapes/rect" 26 | export { Rect } 27 | import Poly from "./shapes/poly" 28 | export { Poly } 29 | import PolyLine from "./shapes/poly-line" 30 | export { PolyLine } 31 | import Point from "./shapes/point" 32 | export { Point } 33 | import Math from "./math/math" 34 | export { Math } 35 | import { simpleHull_2D } from "./math/convex-hull" 36 | export { simpleHull_2D } 37 | -------------------------------------------------------------------------------- /src/core/mat2.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | import { mat2 as Mat2 } from "gl-matrix" 4 | import { EPSILON } from "./configure" 5 | 6 | /** 7 | * Overwrites https://github.com/toji/gl-matrix/blob/v3.3.0/src/mat2.js#L379 8 | * since there is no way to configure the global epsilon used for floating pt 9 | * comparisons. 10 | * 11 | * Returns whether or not the vectors have approximately the same elements in the same position. 12 | * 13 | * @param {Vec2d} a The first vector. 14 | * @param {Vec2d} b The second vector. 15 | * @param {Number} [epsilon=null] Optional epsilon value to use for the comparison. If null, uses 16 | * the globally-configured epsilon. 17 | * @returns {Boolean} True if the vectors are equal, false otherwise. 18 | */ 19 | Mat2.equals = function(a, b, epsilon = null) { 20 | const a0 = a[0], 21 | a1 = a[1], 22 | a2 = a[2], 23 | a3 = a[3] 24 | const b0 = b[0], 25 | b1 = b[1], 26 | b2 = b[2], 27 | b3 = b[3] 28 | const eps = epsilon !== null ? epsilon : EPSILON 29 | return ( 30 | Math.abs(a0 - b0) <= eps * Math.max(1.0, Math.abs(a0), Math.abs(b0)) && 31 | Math.abs(a1 - b1) <= eps * Math.max(1.0, Math.abs(a1), Math.abs(b1)) && 32 | Math.abs(a2 - b2) <= eps * Math.max(1.0, Math.abs(a2), Math.abs(b2)) && 33 | Math.abs(a3 - b3) <= eps * Math.max(1.0, Math.abs(a3), Math.abs(b3)) 34 | ) 35 | } 36 | 37 | export default Mat2 38 | -------------------------------------------------------------------------------- /src/style/basic-style.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | import aggregation from "../util/aggregation" 4 | import FillStyle from "../style/fill-style" 5 | import StrokeStyle from "../style/stroke-style" 6 | 7 | /** 8 | * @class Basic shape style for a 2d rendering context 9 | * @extends {FillStyle} 10 | * @extends {StrokeStyle} 11 | */ 12 | export default class BasicStyle extends aggregation( 13 | class BaseBasicStyle {}, 14 | FillStyle, 15 | StrokeStyle 16 | ) { 17 | /** 18 | * Copies the properties from one BasicStyle to another 19 | * @param {BasicStyle} srcBasicStyle The style to copy from 20 | * @param {BasicStyle} dstBasicStyle The style to copy to 21 | */ 22 | static copyBasicStyle(srcBasicStyle, dstBasicStyle) { 23 | FillStyle.copyFillStyle(srcBasicStyle, dstBasicStyle) 24 | StrokeStyle.copyStrokeStyle(srcBasicStyle, dstBasicStyle) 25 | } 26 | 27 | /** 28 | * Converts a BasicStyle instance to a JSON object 29 | * @param {BasicStyle} basicStyleObj 30 | * @return {{fillColor : string, 31 | * strokeColor : string, 32 | * strokeWidth : number, 33 | * lineJoin : string, 34 | * lineCap : string, 35 | * dashPattern : number[], 36 | * dashOffset : number 37 | * }} 38 | */ 39 | static toJSON(basicStyleObj) { 40 | return Object.assign( 41 | FillStyle.toJSON(basicStyleObj), 42 | StrokeStyle.toJSON(basicStyleObj) 43 | ) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/shapes/poly.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | import PolyLine from "./poly-line" 4 | import * as Point2d from "../core/point2d" 5 | 6 | const Constants = { 7 | MINIMUM_POINTS: 3 8 | } 9 | 10 | export default class Poly extends PolyLine { 11 | constructor(opts) { 12 | super(opts) 13 | if (this._verts.length < Constants.MINIMUM_POINTS) { 14 | throw new Error( 15 | "Poly shapes must be initialized with an array of 2d points and contain at least 3 points" 16 | ) 17 | } 18 | } 19 | 20 | removeVert(vertIndex) { 21 | if (this._verts.length < Constants.MINIMUM_POINTS) { 22 | throw new Error( 23 | `Cannot remove vertex ${vertIndex}. It would result in a poly with < 3 points. A poly must contain at least 3 points.` 24 | ) 25 | } 26 | 27 | super.removeVert(vertIndex) 28 | } 29 | 30 | _draw(ctx) { 31 | let rtn = false 32 | if (this._verts.length >= Constants.MINIMUM_POINTS) { 33 | ctx.setTransform(1, 0, 0, 1, 0, 0) 34 | const proj_pt = Point2d.create() 35 | Point2d.transformMat2d(proj_pt, this._verts[0], this._fullXform) 36 | ctx.moveTo(proj_pt[0], proj_pt[1]) 37 | for (let i = 1; i < this._verts.length; i += 1) { 38 | Point2d.transformMat2d(proj_pt, this._verts[i], this._fullXform) 39 | ctx.lineTo(proj_pt[0], proj_pt[1]) 40 | } 41 | ctx.closePath() 42 | rtn = true 43 | } 44 | return rtn 45 | } 46 | 47 | // eslint-disable-next-line indent 48 | toJSON() { 49 | return Object.assign(super.toJSON(), { 50 | /* eslint-disable indent */ 51 | type: "Poly" // NOTE: this much match the name of the class 52 | // This is also supplied after the super.toJSON() 53 | // so that this type overrides the parent class's 54 | // type 55 | /* eslint-enable indent */ 56 | }) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/math/math.js: -------------------------------------------------------------------------------- 1 | import { EPSILON } from "../core/configure" 2 | 3 | const quarter = 0.25 4 | const half = 0.5 5 | const two = 2 6 | 7 | Math.QUATER_PI = quarter * Math.PI 8 | Math.HALF_PI = half * Math.PI 9 | Math.HALF_NPI = -half * Math.PI 10 | Math.TWO_PI = two * Math.PI 11 | Math.NPI = -Math.PI 12 | Math.NQUATER_PI = quarter * Math.NPI 13 | Math.NHALF_PI = half * Math.NPI 14 | Math.NTWO_PI = two * Math.NPI 15 | Math.INV_PI = 1 / Math.PI 16 | Math.RAD_TO_DEG = 180 / Math.PI 17 | Math.DEG_TO_RAD = Math.PI / 180 18 | 19 | /** 20 | * Clamp f to be between a min and max. 21 | * @param {Number} f 22 | * @param {Number} minv 23 | * @param {Number} maxv 24 | * @return {Number} 25 | */ 26 | function clamp(f, minv, maxv) { 27 | return f < minv ? minv : f > maxv ? maxv : f 28 | } 29 | 30 | /** 31 | * Clamp f to be between 0 and 1. 32 | * @param {Number} f 33 | * @return {Number} 34 | */ 35 | function clamp01(f) { 36 | return f < 0 ? 0 : f > 1 ? 1 : f 37 | } 38 | 39 | /** 40 | * Linearly interpolate (or extrapolate) between @c f1 and @c f2 by @c t percent. 41 | * @param {Number} f1 42 | * @param {Number} f2 43 | * @param {Number} t 44 | * @return {Number} 45 | */ 46 | function lerp(f1, f2, t) { 47 | return f1 * (1 - t) + f2 * t 48 | } 49 | 50 | Math.clamp = clamp 51 | Math.clamp01 = clamp01 52 | Math.lerp = lerp 53 | 54 | /** 55 | * Tests whether or not the arguments have approximately the same value, within an absolute 56 | * or relative tolerance of glMatrix.EPSILON (an absolute tolerance is used for values less 57 | * than or equal to 1.0, and a relative tolerance is used for larger values) 58 | * 59 | * @param {Number} a The first number to test. 60 | * @param {Number} b The second number to test. 61 | * @param {Number} [epsilon=null] Optional epsilon value to use for the comparison. If null, uses 62 | * the globally-configured epsilon. 63 | * @returns {Boolean} True if the numbers are approximately equal, false otherwise. 64 | */ 65 | Math.floatingPtEquals = function(a, b, epsilon = null) { 66 | const eps = epsilon !== null ? epsilon : EPSILON 67 | return Math.abs(a - b) <= eps * Math.max(1.0, Math.abs(a), Math.abs(b)) 68 | } 69 | 70 | export default Math 71 | -------------------------------------------------------------------------------- /example/exampleShapeBuilder.html: -------------------------------------------------------------------------------- 1 | - 2 | 3 | 4 | 5 | 6 | HEAVY.AI 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 | 16 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /test/core/point2d.unit.spec.js: -------------------------------------------------------------------------------- 1 | import chai, {expect} from 'chai' 2 | import * as Point2D from '../../src/core/point2d.js' 3 | 4 | describe("Point2D", () => { 5 | describe("set", () => { 6 | it("should be a function", () => { 7 | expect(typeof Point2D.set).to.equal("function") 8 | }) 9 | }) 10 | 11 | describe("create", () => { 12 | it("should be a function", () => { 13 | expect(typeof Point2D.create).to.equal("function") 14 | }) 15 | }) 16 | 17 | describe("clone", () => { 18 | it("should be a function", () => { 19 | expect(typeof Point2D.clone).to.equal("function") 20 | }) 21 | }) 22 | 23 | describe("copy", () => { 24 | it("should be a function", () => { 25 | expect(typeof Point2D.copy).to.equal("function") 26 | }) 27 | }) 28 | 29 | describe("initFromValues", () => { 30 | it("should be a function", () => { 31 | expect(typeof Point2D.initFromValues).to.equal("function") 32 | }) 33 | }) 34 | 35 | describe("addVec2", () => { 36 | it("should be a function", () => { 37 | expect(typeof Point2D.addVec2).to.equal("function") 38 | }) 39 | }) 40 | 41 | describe("sub", () => { 42 | it("should be a function", () => { 43 | expect(typeof Point2D.sub).to.equal("function") 44 | }) 45 | }) 46 | 47 | describe("transformMat2", () => { 48 | it("should be a function", () => { 49 | expect(typeof Point2D.transformMat2).to.equal("function") 50 | }) 51 | }) 52 | 53 | describe("str", () => { 54 | it("should be a function", () => { 55 | expect(typeof Point2D.str).to.equal("function") 56 | }) 57 | }) 58 | 59 | describe("distance", () => { 60 | it("should be a function", () => { 61 | expect(typeof Point2D.distance).to.equal("function") 62 | }) 63 | }) 64 | 65 | describe("squaredDistance", () => { 66 | it("should be a function", () => { 67 | expect(typeof Point2D.squaredDistance).to.equal("function") 68 | }) 69 | }) 70 | 71 | describe("linear interpolation", () => { 72 | it("should be a function", () => { 73 | expect(typeof Point2D.lerp).to.equal("function") 74 | }) 75 | }) 76 | 77 | describe("equals", () => { 78 | it("should be a function", () => { 79 | expect(typeof Point2D.equals).to.equal("function") 80 | }) 81 | }) 82 | 83 | describe("exactEquals", () => { 84 | it("should be a function", () => { 85 | expect(typeof Point2D.exactEquals).to.equal("function") 86 | }) 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /src/core/vec2d.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | import { vec2 as Vec2d } from "gl-matrix" 4 | import { EPSILON } from "./configure" 5 | 6 | /** 7 | * Overwrites https://github.com/toji/gl-matrix/blob/v3.3.0/src/vec2.js#L524 8 | * since there is no way to configure the global epsilon used for floating pt 9 | * comparisons. 10 | * 11 | * Returns whether or not the vectors have approximately the same elements in the same position. 12 | * 13 | * @param {Vec2d} a The first vector. 14 | * @param {Vec2d} b The second vector. 15 | * @param {Number} [epsilon=null] Optional epsilon value to use for the comparison. If null, uses 16 | * the globally-configured epsilon. 17 | * @returns {Boolean} True if the vectors are equal, false otherwise. 18 | */ 19 | Vec2d.equals = function(a, b, epsilon = null) { 20 | const a0 = a[0], 21 | a1 = a[1] 22 | const b0 = b[0], 23 | b1 = b[1] 24 | const eps = epsilon !== null ? epsilon : EPSILON 25 | return ( 26 | Math.abs(a0 - b0) <= eps * Math.max(1.0, Math.abs(a0), Math.abs(b0)) && 27 | Math.abs(a1 - b1) <= eps * Math.max(1.0, Math.abs(a1), Math.abs(b1)) 28 | ) 29 | } 30 | 31 | /** 32 | * Returns the Z coordinate of a 2D crossproduct 33 | * @param {Vec2d} v1 34 | * @param {Vec2d} v2 35 | * @return {Number} z coord of the 2D crossproduct 36 | */ 37 | Vec2d.cross2d = (v1, v2) => v1[0] * v2[1] - v1[1] * v2[0] 38 | 39 | /** 40 | * Calculates the angle between two vectors when directionality 41 | * is unnecessary (only returns an angle between 0 and PI, inclusive) 42 | * @param {Vec2d} v1 43 | * @param {Vec2d} v2 44 | * @return {Number} The angle between two vectors in radians [0, PI] 45 | */ 46 | Vec2d.angleFast = (v1, v2) => Math.acos(Vec2d.dot(v1, v2)) 47 | 48 | /** 49 | * Returns the true angle between two vectors 50 | * @param {Vec2d} v1 51 | * @param {Vec2d} v2 52 | * @return {Number} The angle between two vectors in radians [-PI, PI] 53 | */ 54 | Vec2d.angle = (v1, v2) => { 55 | const c = Vec2d.dot(v1, v2) 56 | const s = Vec2d.cross2d(v1, v2) 57 | const angle = Math.atan2(s, c) 58 | return angle 59 | } 60 | 61 | /** 62 | * Returns the angle of a vector from the positive X direction 63 | * in a cartesian coordinate system 64 | * @param {Vec2d} v1 65 | * @return {Number} The angle in radians [-PI, PI] 66 | */ 67 | Vec2d.anglePosX = v => { 68 | let angle = Math.atan2(v[1], v[0]) 69 | if (angle < 0) { 70 | angle *= -1 71 | } 72 | return angle 73 | } 74 | 75 | export default Vec2d 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@heavyai/draw", 3 | "description": "A Utility Library for drawing and interacting with shapes using canvas", 4 | "version": "2.0.0", 5 | "homepage": "https://heavy.ai", 6 | "bugs": "https://github.com/omnisci/mapd-draw/issues", 7 | "main": "dist/draw.js", 8 | "author": "HEAVY.AI", 9 | "license": "MIT", 10 | "engines": { 11 | "node": ">=4.0.0" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/omnisci/mapd-draw.git" 16 | }, 17 | "dependencies": { 18 | "css-element-queries": "^0.4.0", 19 | "gl-matrix": "^2.3.2" 20 | }, 21 | "devDependencies": { 22 | "@babel/core": "^7.0.0", 23 | "@babel/plugin-proposal-class-properties": "^7.0.0", 24 | "@babel/plugin-proposal-decorators": "^7.0.0", 25 | "@babel/plugin-proposal-do-expressions": "^7.0.0", 26 | "@babel/plugin-proposal-export-default-from": "^7.0.0", 27 | "@babel/plugin-proposal-export-namespace-from": "^7.0.0", 28 | "@babel/plugin-proposal-function-bind": "^7.0.0", 29 | "@babel/plugin-proposal-function-sent": "^7.0.0", 30 | "@babel/plugin-proposal-json-strings": "^7.0.0", 31 | "@babel/plugin-proposal-logical-assignment-operators": "^7.0.0", 32 | "@babel/plugin-proposal-nullish-coalescing-operator": "^7.0.0", 33 | "@babel/plugin-proposal-numeric-separator": "^7.0.0", 34 | "@babel/plugin-proposal-optional-chaining": "^7.0.0", 35 | "@babel/plugin-proposal-pipeline-operator": "^7.0.0", 36 | "@babel/plugin-proposal-throw-expressions": "^7.0.0", 37 | "@babel/plugin-syntax-dynamic-import": "^7.0.0", 38 | "@babel/plugin-syntax-import-meta": "^7.0.0", 39 | "@babel/preset-env": "^7.0.0", 40 | "@babel/register": "^7.0.0", 41 | "babel-eslint": "^10.0.1", 42 | "babel-loader": "^8.0.0", 43 | "chai": "^3.5.0", 44 | "eslint": "^6.0.0", 45 | "eslint-config-prettier": "^6.5.0", 46 | "eslint-plugin-prettier": "^3.1.1", 47 | "json-loader": "0.5.4", 48 | "mocha": "^6.2.1", 49 | "pre-commit": "^1.2.2", 50 | "prettier": "1.18.2", 51 | "webpack": "^3.12.0", 52 | "webpack-dev-server": "^2.11.3" 53 | }, 54 | "scripts": { 55 | "build": "npm run build:prod; npm run build:dev", 56 | "build:prod": "npm run webpack", 57 | "build:dev": "npm run webpack:dev", 58 | "clean": "bash scripts/clean.sh", 59 | "lint:fix": "eslint --rule 'prettier/prettier: 0' --fix $(find src -name \"*.js\" ! -name '*.spec.js'); prettier --write $(find src -name \"*.js\" ! -name '*.spec.js')", 60 | "lint": "eslint $(find src -name \"*.js\" ! -name '*.spec.js')", 61 | "start": "webpack-dev-server --config webpack.dev.config.js --content-base ./example --watch -d --open", 62 | "watch:dev": "node node_modules/webpack/bin/webpack.js --progress --colors --watch --config webpack.dev.config.js", 63 | "webpack": "node node_modules/webpack/bin/webpack.js", 64 | "webpack:dev": "node node_modules/webpack/bin/webpack.js --config webpack.dev.config.js", 65 | "test": "mocha --recursive --require @babel/register" 66 | }, 67 | "pre-commit": [ 68 | "lint", 69 | "test" 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /src/core/mat2d.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | import { mat2d as Mat2d } from "gl-matrix" 4 | import { EPSILON } from "./configure" 5 | 6 | /** 7 | * Overwrites https://github.com/toji/gl-matrix/blob/v3.3.0/src/vec2.js#L524 8 | * since there is no way to configure the global epsilon used for floating pt 9 | * comparisons. 10 | * 11 | * Returns whether or not the matrices have approximately the same elements in the same position. 12 | * 13 | * @param {ReadonlyMat2d} a The first matrix. 14 | * @param {ReadonlyMat2d} b The second matrix. 15 | * @param {Number} [epsilon=null] Optional epsilon value to use for the comparison. If null, uses 16 | * the globally-configured epsilon. 17 | * @returns {Boolean} True if the matrices are equal, false otherwise. 18 | */ 19 | Mat2d.equals = function equals(a, b, epsilon = null) { 20 | const a0 = a[0], 21 | a1 = a[1], 22 | a2 = a[2], 23 | a3 = a[3], 24 | a4 = a[4], 25 | a5 = a[5] 26 | const b0 = b[0], 27 | b1 = b[1], 28 | b2 = b[2], 29 | b3 = b[3], 30 | b4 = b[4], 31 | b5 = b[5] 32 | const eps = epsilon !== null ? epsilon : EPSILON 33 | return ( 34 | Math.abs(a0 - b0) <= eps * Math.max(1.0, Math.abs(a0), Math.abs(b0)) && 35 | Math.abs(a1 - b1) <= eps * Math.max(1.0, Math.abs(a1), Math.abs(b1)) && 36 | Math.abs(a2 - b2) <= eps * Math.max(1.0, Math.abs(a2), Math.abs(b2)) && 37 | Math.abs(a3 - b3) <= eps * Math.max(1.0, Math.abs(a3), Math.abs(b3)) && 38 | Math.abs(a4 - b4) <= eps * Math.max(1.0, Math.abs(a4), Math.abs(b4)) && 39 | Math.abs(a5 - b5) <= eps * Math.max(1.0, Math.abs(a5), Math.abs(b5)) 40 | ) 41 | } 42 | 43 | /** 44 | * Singular value decomposition 45 | * See: http://math.stackexchange.com/questions/861674/decompose-a-2d-arbitrary-transform-into-only-scaling-and-rotation 46 | */ 47 | 48 | /** 49 | * Calculates the singular value decomposition to extract the 50 | * scale, rotation, and translation from a 2x3 matrix. 51 | * Any matrix built by affine transformations can be decomposed 52 | * into a rotation*scale*rotation*translation 53 | * See: http://math.stackexchange.com/questions/861674/decompose-a-2d-arbitrary-transform-into-only-scaling-and-rotation 54 | * @param {Vec2d} outTranslate Vector to hold the translation components 55 | * @param {Vec2d} outScale Vector to hold the x,y scale components 56 | * @param {Vec2d} outRotate Vector to hold the rotation components 57 | * @param {Mat2d} mat Matrix to decompose 58 | */ 59 | Mat2d.svd = (outTranslate, outScale, outRotate, mat) => { 60 | if (outTranslate) { 61 | outTranslate[0] = mat[4] 62 | outTranslate[1] = mat[5] 63 | } 64 | if (outScale || outRotate) { 65 | const E = (mat[0] + mat[3]) / 2.0 66 | const F = (mat[0] - mat[3]) / 2.0 67 | const G = (mat[1] + mat[2]) / 2.0 68 | const H = (mat[1] - mat[2]) / 2.0 69 | if (outScale) { 70 | const Q = Math.sqrt(E * E + H * H) 71 | const R = Math.sqrt(F * F + G * G) 72 | outScale[0] = Q + R 73 | outScale[1] = Q - R 74 | } 75 | if (outRotate) { 76 | const a1 = Math.atan2(G, F) 77 | const a2 = Math.atan2(H, E) 78 | outRotate[0] = (a2 - a1) / 2.0 79 | outRotate[1] = (a2 + a1) / 2.0 80 | } 81 | } 82 | } 83 | 84 | export default Mat2d 85 | -------------------------------------------------------------------------------- /src/util/aggregation.js: -------------------------------------------------------------------------------- 1 | /* 2 | ** Aggregation -- Aggregation of Base Class and Mixin Classes 3 | ** Copyright (c) 2015 Ralf S. Engelschall 4 | ** 5 | ** Permission is hereby granted, free of charge, to any person obtaining 6 | ** a copy of this software and associated documentation files (the 7 | ** "Software"), to deal in the Software without restriction, including 8 | ** without limitation the rights to use, copy, modify, merge, publish, 9 | ** distribute, sublicense, and/or sell copies of the Software, and to 10 | ** permit persons to whom the Software is furnished to do so, subject to 11 | ** the following conditions: 12 | ** 13 | ** The above copyright notice and this permission notice shall be included 14 | ** in all copies or substantial portions of the Software. 15 | ** 16 | ** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | ** EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | ** MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | ** IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | ** CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | ** TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | ** SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | */ 24 | 25 | /* ==== ECMAScript 6 variant ==== */ 26 | 27 | /** 28 | * Creates a new class constructor function using an optional base class 29 | * and an optional list of mixins. If mixins need initializing, then 30 | * they should contain an 'initializer' member function 31 | * @param {function} base base class construct function, if null, a bare-bones 32 | * base class is used by default 33 | * @param {...[function]} mixins mixin classes 34 | * @return {function} new class constructor function 35 | */ 36 | const aggregation = (base, ...mixins) => { 37 | /* create aggregation class */ 38 | const aggregate = base 39 | ? class __Aggregate extends base { 40 | constructor(...args) { 41 | /* call base class constructor */ 42 | super(...args) 43 | 44 | /* call mixin's initializer */ 45 | mixins.forEach(mixin => { 46 | if (typeof mixin.prototype.initializer === "function") { 47 | mixin.prototype.initializer.call(this, ...args) 48 | } 49 | }) 50 | } 51 | } 52 | : () => { 53 | /* do nothing */ 54 | } 55 | 56 | /* copy properties */ 57 | const copyProps = (target, source) => { 58 | Object.getOwnPropertyNames(source) 59 | .concat(Object.getOwnPropertySymbols(source)) 60 | .forEach(prop => { 61 | if ( 62 | prop.match( 63 | /^(?:constructor|prototype|arguments|caller|name|bind|call|apply|toString|length)$/ 64 | ) 65 | ) { 66 | return 67 | } 68 | if (base && prop.match(/^(?:initializer)$/)) { 69 | return 70 | } 71 | Object.defineProperty( 72 | target, 73 | prop, 74 | Object.getOwnPropertyDescriptor(source, prop) 75 | ) 76 | }) 77 | } 78 | 79 | /* copy all properties of all mixins into aggregation class */ 80 | mixins.forEach(mixin => { 81 | copyProps(aggregate.prototype, mixin.prototype) 82 | copyProps(aggregate, mixin) 83 | }) 84 | 85 | return aggregate 86 | } 87 | 88 | export default aggregation 89 | -------------------------------------------------------------------------------- /src/math/convex-hull.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | // Copyright 2001 softSurfer, 2012 Dan Sunday 3 | // This code may be freely used and modified for any purpose 4 | // providing that this copyright notice is included with it. 5 | // SoftSurfer makes no warranty for this code, and cannot be held 6 | // liable for any real or imagined damage resulting from its use. 7 | // Users of this code must verify correctness for their application. 8 | 9 | // from http://geomalgorithms.com/a12-_hull-3.html 10 | 11 | "use strict" 12 | 13 | const X = 0 14 | const Y = 1 15 | 16 | /** 17 | * Test if a point is Left|On|Right of an infinite line. 18 | * @param {Point2d} P0 [description] 19 | * @param {Point2d} P1 [description] 20 | * @param {Point2d} P2 [description] 21 | * @return {Boolean} Returns > 0 if P2 is left of the line thru P0 & P1, 22 | * Returns < 0 if P2 is to the right 23 | * Returns 0 if P2 is on the line 24 | */ 25 | function isLeft(P0, P1, P2) { 26 | return (P1[X] - P0[X]) * (P2[Y] - P0[Y]) - (P2[X] - P0[X]) * (P1[Y] - P0[Y]) 27 | } 28 | 29 | /** 30 | * Melkman's 2D simple polyline O(n) convex hull algorithm 31 | * @param {Point2d[]} verts [description] 32 | * @return {number[]} [description] 33 | */ 34 | export function simpleHull_2D(verts) { 35 | // initialize a deque D[] from bottom to top so that the 36 | // 1st three vertices of P[] are a ccw triangle 37 | const H = [] 38 | const n = verts.length 39 | 40 | if (n < 3) { 41 | for (let i = 0; i < n; i += 1) { 42 | H[i] = i 43 | } 44 | return H 45 | } 46 | 47 | const D = new Array(2 * n + 1) 48 | D.fill(-1) 49 | let bot = n - 2 50 | let top = bot + 3 // initial bottom and top deque indices 51 | 52 | D[bot] = D[top] = 2 // 3rd vertex is at both bot and top 53 | if (isLeft(verts[0], verts[1], verts[2]) > 0) { 54 | D[bot + 1] = 0 55 | D[bot + 2] = 1 // ccw vertices are: 2,0,1,2 56 | } else { 57 | D[bot + 1] = 1 58 | D[bot + 2] = 0 // ccw vertices are: 2,1,0,2 59 | } 60 | 61 | // compute the hull on the deque D[] 62 | for (let i = 3; i < n; i += 1) { 63 | // process the rest of vertices 64 | // test if next vertex is outside the deque hull 65 | if ( 66 | isLeft(verts[D[bot]], verts[D[bot + 1]], verts[i]) <= 0 || 67 | isLeft(verts[D[top - 1]], verts[D[top]], verts[i]) <= 0 68 | ) { 69 | // incrementally add an exterior vertex to the deque hull 70 | // get the rightmost tangent at the deque bot 71 | while ( 72 | D[bot] >= 0 && 73 | D[bot + 1] >= 0 && 74 | isLeft(verts[D[bot]], verts[D[bot + 1]], verts[i]) <= 0 75 | ) { 76 | bot += 1 // remove bot of deque 77 | } 78 | bot -= 1 79 | D[bot] = i // insert verts[i] at bot of deque 80 | 81 | // get the leftmost tangent at the deque top 82 | while ( 83 | D[top] >= 0 && 84 | D[top + 1] >= 0 && 85 | isLeft(verts[D[top - 1]], verts[D[top]], verts[i]) <= 0 86 | ) { 87 | top -= 1 // pop top of deque 88 | } 89 | top += 1 90 | D[top] = i // push verts[i] onto top of deque 91 | } 92 | } 93 | 94 | // transcribe deque D[] to the output hull array H[] 95 | let h = 0 96 | for (h = 0; h <= top - bot - 1; h += 1) { 97 | H[h] = D[bot + h] 98 | } 99 | 100 | if (D[bot + h] !== H[0]) { 101 | H[h] = D[bot + h] 102 | } 103 | 104 | return H 105 | } 106 | -------------------------------------------------------------------------------- /src/shapes/point.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | import * as AABox2d from "../core/aabox2d" 4 | import BaseShape from "./base-shape.js" 5 | import Mat2d from "../core/mat2d" 6 | import Math from "../math/math" 7 | import * as Point2d from "../core/point2d" 8 | 9 | /** 10 | * @typedef {object} PointOptions 11 | * @property {number} [size=5] Size of the point in pixels 12 | */ 13 | 14 | /** 15 | * @class Point shape class. A point differs from a circle in that 16 | * a point's size is defined in screen/pixel space whereas 17 | * a circle is defined in world space. 18 | * @extends {BaseShape} 19 | */ 20 | export default class Point extends BaseShape { 21 | /** 22 | * Creates a new point shape 23 | * @param {PointOptions} [opts] 24 | * @return {Point} 25 | */ 26 | constructor(opts) { 27 | super(opts) 28 | this._size = 5 29 | if (typeof opts.size !== "undefined") { 30 | this.size = opts.size 31 | } 32 | AABox2d.initCenterExtents(this._aabox, Point2d.create(0, 0), [ 33 | this._radius, 34 | this._radius 35 | ]) 36 | this.translate(opts.x || 0, opts.y || 0) 37 | } 38 | 39 | /** 40 | * Sets the size of the point 41 | * @param {nuber} size Size of the point in pixels 42 | * @return {Pixel} this 43 | * @fires {Shape#geomChanged} 44 | * @throws {Error} If size is not a valid number 45 | */ 46 | set size(size) { 47 | if (typeof size !== "number") { 48 | throw new Error("Radius must be a number") 49 | } 50 | 51 | if (size !== this._size) { 52 | const prev = this._size 53 | this._size = size 54 | this._geomDirty = true // dirty needs to be set before firing event 55 | 56 | this.fire("changed:geom", { 57 | attr: "size", 58 | prevVal: prev, 59 | currVal: this._size 60 | }) 61 | } 62 | } 63 | 64 | /** 65 | * Gets the current size of the point 66 | * @return {number} 67 | */ 68 | get size() { 69 | return this._size 70 | } 71 | 72 | /** 73 | * Called when the bounding box requires updating 74 | * @private 75 | * @override 76 | */ 77 | _updateAABox() { 78 | if (this._geomDirty || this._boundsOutOfDate) { 79 | const pos = this._pos 80 | const scale = this._scale 81 | const rot = Math.DEG_TO_RAD * this._rotDeg 82 | const cossqr = Math.pow(Math.cos(rot), 2) 83 | const sinsqr = Math.pow(Math.sin(rot), 2) 84 | const asqr = Math.pow(scale[0] * this._size, 2) 85 | const bsqr = Math.pow(scale[1] * this._size, 2) 86 | const A = Math.sqrt(bsqr * sinsqr + asqr * cossqr) 87 | const B = Math.sqrt(asqr * sinsqr + bsqr * cossqr) 88 | AABox2d.initCenterExtents(this._aabox, pos, [A, B]) 89 | this._geomDirty = false 90 | this._boundsOutOfDate = false 91 | } 92 | } 93 | 94 | /** 95 | * Draws the point using a 2d rendering context. Called by the BaseShape 96 | * class 97 | * @param {CanvasRenderingContext2d} ctx 2d rendering context 98 | * @override 99 | */ 100 | _draw(ctx) { 101 | ctx.setTransform(1, 0, 0, 1, 0, 0) 102 | const pos = Point2d.create() 103 | Mat2d.svd(pos, null, null, this._fullXform) 104 | ctx.arc(pos[0], pos[1], this._size, 0, Math.TWO_PI, false) 105 | } 106 | 107 | /** 108 | * Called to convert the shape to a serializable JSON object 109 | * @return {object} 110 | * @override 111 | */ 112 | toJSON() { 113 | return Object.assign( 114 | { 115 | type: "Point", // NOTE: this much match the name of the class 116 | size: this.size 117 | }, 118 | super.toJSON() 119 | ) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /.github/CONTRIBUTOR_LICENSE_AGREEMENT.md: -------------------------------------------------------------------------------- 1 | MAPD TECHNOLOGIES CONTRIBUTOR LICENSE AGREEMENT 2 | 3 | This Contributor License Agreement (“Agreement”) is between the contributor specified below (“you”) and MapD Technologies, Inc. (“we” or “us”). This Agreement applies to all Contributions (as defined below) submitted by you to an open source project owned or managed by us (each, a “Project”). 4 | 5 | Definitions. The terms “you” or “your” shall mean the copyright owner or the individual or legal entity authorized by the copyright owner that is granting the licenses under this Agreement. For legal entities, the term “you” includes any entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity. “Contribution” means any work of authorship, including any source code, object code, patch, tool, sample, graphic, image, audio or audiovisual work, specification, manual, and documentation, and any modifications or additions to an existing work, submitted by you in connection with any product or other item developed, managed or maintained by a Project (collectively, such products and other items, “Work”). The term “submitted” means any form of electronic, verbal, or written communication sent to a Project or its representatives, including communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by or on behalf of a Project. 6 | 7 | Copyright License. You hereby grant to us and to recipients of products or other items distributed by a Project a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, transmit, sublicense, and distribute your Contributions and derivative works thereof. 8 | 9 | Patent License. You hereby grant to us and to recipients of products or other items distributed by the Project a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by you that are necessarily infringed by your Contribution(s) alone or by the combination of your Contribution(s) with the Work to which such Contribution(s) was submitted. 10 | 11 | Authority. You represent that you are legally entitled to grant the licenses specified above. If your employer or other entity that you are associated with has rights to intellectual property that you create which includes your Contributions, you represent that you have received authorization to make Contributions and grant the foregoing licenses on behalf of that employer or entity in accordance with this Agreement. 12 | 13 | Originality. You represent that each of your Contributions is your original creation (see section 7 below for submissions on behalf of others). You represent that your Contributions include complete details of any third-party license or other restriction (including related patents and trademarks) of which you are personally aware and which are associated with any part of your Contributions. 14 | 15 | No Warranty. You are not expected to provide support for your Contributions. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, you provide your Contributions on an “AS IS” BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, NON- INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. 16 | 17 | Submissions of Third Party Contributions. Should you wish to submit work that is not your original creation, you may submit it to the Project Leads separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as “Submitted on behalf of a third-party: [named here].” 18 | 19 | Notification. You will notify us of any facts or circumstances of which you become aware that would make the foregoing representations inaccurate in any respect or would call into question the grants of rights hereunder. 20 | 21 | Miscellaneous. This Agreement is the exclusive agreement between the parties with respect to the subject matter hereof. We may freely assign this Agreement. This Agreement shall be governed by the laws of the state of California. 22 | 23 | Contributor 24 | 25 | Signature: 26 | 27 | Printed Name: 28 | 29 | Date: 30 | 31 | Address: 32 | 33 | SV\821532.2 34 | -------------------------------------------------------------------------------- /src/util/canvas-utils.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable func-names */ 2 | "use strict" 3 | 4 | /** 5 | * Gets the pixel ratio of a specific HTML canvas 2d context 6 | * @param {CanvasRenderingContext2D} canvasCtx 7 | * @return {number} pixel ratio of the canvas 2d context 8 | */ 9 | export function getPixelRatio(canvasCtx) { 10 | const backingStore = 11 | canvasCtx.backingStorePixelRatio || 12 | canvasCtx.webkitBackingStorePixelRatio || 13 | canvasCtx.mozBackingStorePixelRatio || 14 | canvasCtx.msBackingStorePixelRatio || 15 | canvasCtx.oBackingStorePixelRatio || 16 | canvasCtx.backingStorePixelRatio || 17 | 1 18 | 19 | return (window.devicePixelRatio || 1) / backingStore 20 | } 21 | 22 | /** 23 | * Wraps certain canvas 2d context APIs to handle 24 | * displays with high per-pixel ratios. This is useful 25 | * so that the API can be called with screen-space coordinates 26 | * and the wrappers modify the arguments of those APIs to properly 27 | * handle displays with high pixel densities 28 | * @param {CanvasRenderingContext2D} canvasCtx 29 | * @return {number} pixel ratio of the wrapped canvas context 30 | */ 31 | export function makeCanvasAutoHighDPI(canvasCtx) { 32 | const pixelRatio = getPixelRatio(canvasCtx) 33 | 34 | if (pixelRatio === 1) { 35 | return 1 36 | } 37 | 38 | const allRatioArgs = [ 39 | "fillRect", 40 | "clearRect", 41 | "strokeRect", 42 | // "moveTo", 43 | // "lineTo", 44 | // "arcTo", 45 | // "bezierCurveTo", 46 | "isPointInPath", 47 | "isPointInStroke" 48 | // "quadraticCurveTo", 49 | // "rect", 50 | // "translate", 51 | // "createRadialGradient", 52 | // "createLinearGradient" 53 | ] 54 | 55 | allRatioArgs.forEach(funcName => { 56 | canvasCtx[funcName] = (function(_super) { 57 | return function(...args) { 58 | args = args.map(a => a * pixelRatio) 59 | 60 | return _super.apply(this, args) 61 | } 62 | })(canvasCtx[funcName]) 63 | }) 64 | 65 | // const ratioArgsByIndex = { 66 | // arc: [0, 1, 2] 67 | // } 68 | 69 | // Object.getOwnPropertyNames(ratioArgsByIndex).forEach(funcName => { 70 | // const value = ratioArgsByIndex[funcName] 71 | // canvasCtx[funcName] = (function(_super) { 72 | // return function(...args) { 73 | // let i = 0 74 | // let len = 0 75 | // for (i = 0, len = value.length; i < len; i += 1) { 76 | // args[value[i]] *= pixelRatio 77 | // } 78 | // return _super.apply(this, args) 79 | // } 80 | // })(canvasCtx[funcName]) 81 | // }) 82 | 83 | // // Stroke lineWidth adjustment 84 | // canvasCtx.stroke = (function(_super) { 85 | // return function(...args) { 86 | // this.lineWidth *= pixelRatio 87 | // _super.apply(this, args) 88 | // this.lineWidth /= pixelRatio 89 | // } 90 | // })(canvasCtx.stroke) 91 | 92 | // // Text 93 | // // 94 | // canvasCtx.fillText = (function(_super) { 95 | // return function(...args) { 96 | // args[1] *= pixelRatio // x 97 | // args[2] *= pixelRatio // y 98 | 99 | // this.font = this.font.replace( 100 | // /(\d+)(px|em|rem|pt)/g, 101 | // function(w, m, u) { 102 | // return (m * pixelRatio) + u 103 | // } 104 | // ) 105 | 106 | // _super.apply(this, args) 107 | 108 | // this.font = this.font.replace( 109 | // /(\d+)(px|em|rem|pt)/g, 110 | // function(w, m, u) { 111 | // return (m / pixelRatio) + u 112 | // } 113 | // ) 114 | // } 115 | // })(canvasCtx.fillText) 116 | 117 | // canvasCtx.strokeText = (function(_super) { 118 | // return function(...args) { 119 | // args[1] *= pixelRatio // x 120 | // args[2] *= pixelRatio // y 121 | 122 | // this.font = this.font.replace( 123 | // /(\d+)(px|em|rem|pt)/g, 124 | // function(w, m, u) { 125 | // return (m * pixelRatio) + u 126 | // } 127 | // ) 128 | 129 | // _super.apply(this, args) 130 | 131 | // this.font = this.font.replace( 132 | // /(\d+)(px|em|rem|pt)/g, 133 | // function(w, m, u) { 134 | // return (m / pixelRatio) + u 135 | // } 136 | // ) 137 | // } 138 | // })(canvasCtx.strokeText) 139 | 140 | const setTransformArgs = [pixelRatio, 0, 0, pixelRatio, 0, 0] 141 | canvasCtx.setTransform = (function(_super) { 142 | return function(...args) { 143 | _super.apply(this, setTransformArgs) 144 | this.transform(...args) 145 | } 146 | })(canvasCtx.setTransform) 147 | 148 | return pixelRatio 149 | } 150 | -------------------------------------------------------------------------------- /src/shapes/circle.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | import * as AABox2d from "../core/aabox2d" 4 | import * as Point2d from "../core/point2d" 5 | import BaseShape from "./base-shape.js" 6 | import Math from "../math/math" 7 | 8 | /** 9 | * @typedef {object} CircleOptions 10 | * @property {number} [radius=10] Radius of the circle in world-space coordinates 11 | */ 12 | 13 | /** 14 | * @class Shape class describing a circle 15 | * @extends {BaseShape} 16 | */ 17 | export default class Circle extends BaseShape { 18 | /** 19 | * Creates a new Circle shape 20 | * @param {CircleOptions} [opts] 21 | * @return {Circle} 22 | */ 23 | constructor(opts) { 24 | super(opts) 25 | this._radius = 10 26 | if (typeof opts.radius !== "undefined") { 27 | this.radius = opts.radius 28 | } 29 | AABox2d.initCenterExtents(this._aabox, Point2d.create(0, 0), [ 30 | this._radius, 31 | this._radius 32 | ]) 33 | } 34 | 35 | /** 36 | * Sets the radius of the circle 37 | * @param {number} radius Radius of circle in world-space coordinates 38 | * @return {Circle} this 39 | * @fires {Shape#geomChanged} 40 | * @throws {Error} If radius is not a valid number 41 | */ 42 | set radius(radius) { 43 | if (typeof radius !== "number") { 44 | throw new Error("Radius must be a number") 45 | } 46 | 47 | if (radius !== this._radius) { 48 | const prev = this._radius 49 | this._radius = radius 50 | this._geomDirty = true // dirty needs to be set before firing event 51 | 52 | this.fire("changed:geom", { 53 | attr: "radius", 54 | prevVal: prev, 55 | currVal: this._radius 56 | }) 57 | } 58 | 59 | return this 60 | } 61 | 62 | /** 63 | * Gets the current radius of the circle 64 | * @return {number} 65 | */ 66 | get radius() { 67 | return this._radius 68 | } 69 | 70 | /** 71 | * Gets the untransformed width/height of the circle 72 | * @return {Vec2d} Width/height of the circle, untransformed 73 | */ 74 | getDimensions() { 75 | const diameter = this.radius * 2 76 | return [diameter, diameter] 77 | } 78 | 79 | /** 80 | * Gets the untransformed width of the circle 81 | * @return {number} 82 | */ 83 | get width() { 84 | return this.radius * 2 85 | } 86 | 87 | /** 88 | * Gets the untransformed height of the circle 89 | * @return {number} 90 | */ 91 | get height() { 92 | return this.radius * 2 93 | } 94 | 95 | /** 96 | * Called when the bounding box requires updating 97 | * @private 98 | * @override 99 | */ 100 | _updateAABox() { 101 | if (this._geomDirty || this._boundsOutOfDate) { 102 | const pos = this._pos 103 | const scale = this._scale 104 | const rot = Math.DEG_TO_RAD * this._rotDeg 105 | const cossqr = Math.pow(Math.cos(rot), 2) 106 | const sinsqr = Math.pow(Math.sin(rot), 2) 107 | const asqr = Math.pow(scale[0] * this._radius, 2) 108 | const bsqr = Math.pow(scale[1] * this._radius, 2) 109 | const A = Math.sqrt(bsqr * sinsqr + asqr * cossqr) 110 | const B = Math.sqrt(asqr * sinsqr + bsqr * cossqr) 111 | AABox2d.initCenterExtents(this._aabox, pos, [A, B]) 112 | this._geomDirty = false 113 | this._boundsOutOfDate = false 114 | } 115 | } 116 | 117 | /** 118 | * Draws the circle using a 2d rendering context. Called by the BaseShape 119 | * class 120 | * @param {CanvasRenderingContext2d} ctx 2d rendering context 121 | * @override 122 | */ 123 | _draw(ctx) { 124 | // NOTE: CanvasRenderingContext2d::setTransform 125 | // (https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/setTransform) 126 | // only has 32-bits of precision when doing matrix multiplication. If you need a higher level 127 | // of precision, then this is going to be prone to instability. 128 | // The only way to counter the inprecision is to draw the circle ourselves, probably by rendering 129 | // a dynamic line-segmented circle, which is doable, but could be tricky to get the proper amount 130 | // of resolution for the circle dynamically. So keeping the 32-bit precision for now. 131 | ctx.setTransform( 132 | this._fullXform[0], 133 | this._fullXform[1], 134 | this._fullXform[2], 135 | this._fullXform[3], 136 | this._fullXform[4], 137 | this._fullXform[5] 138 | ) 139 | ctx.arc(0, 0, this._radius, 0, Math.TWO_PI, false) 140 | } 141 | 142 | /** 143 | * Called to convert the shape to a serializable JSON object 144 | * @return {object} 145 | * @override 146 | */ 147 | toJSON() { 148 | return Object.assign( 149 | { 150 | type: "Circle", // NOTE: this much match the name of the class 151 | radius: this.radius 152 | }, 153 | super.toJSON() 154 | ) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /test/core/aabox2d.unit.spec.js: -------------------------------------------------------------------------------- 1 | import chai, {expect} from 'chai' 2 | import * as AABox2d from '../../src/core/aabox2d.js' 3 | 4 | describe("AABox2d", () => { 5 | describe("set", () => { 6 | it("should be a function", () => { 7 | expect(typeof AABox2d.set).to.equal("function") 8 | }) 9 | 10 | it("should set infinite bounds if no arguments supplied", () => { 11 | expect(AABox2d.set({})).to.deep.equal({ 12 | 0: Infinity, 13 | 1: Infinity, 14 | 2: -Infinity, 15 | 3: -Infinity 16 | }) 17 | }) 18 | 19 | it("should set bounds if arguments supplied", () => { 20 | expect(AABox2d.set({}, 1, 1, 1, 1)).to.deep.equal({ 21 | 0: 1, 22 | 1: 1, 23 | 2: 1, 24 | 3: 1 25 | }) 26 | }) 27 | }) 28 | 29 | describe("create", () => { 30 | it("should be a function", () => { 31 | expect(typeof AABox2d.create).to.equal("function") 32 | }) 33 | }) 34 | 35 | describe("clone", () => { 36 | it("should be a function", () => { 37 | expect(typeof AABox2d.clone).to.equal("function") 38 | }) 39 | }) 40 | 41 | describe("copy", () => { 42 | it("should be a function", () => { 43 | expect(typeof AABox2d.copy).to.equal("function") 44 | }) 45 | }) 46 | 47 | describe("initEmpty", () => { 48 | it("should be a function", () => { 49 | expect(typeof AABox2d.initEmpty).to.equal("function") 50 | }) 51 | }) 52 | 53 | describe("initInfity", () => { 54 | it("should be a function", () => { 55 | expect(typeof AABox2d.initInfinity).to.equal("function") 56 | }) 57 | }) 58 | 59 | describe("initSizeFromOrigin", () => { 60 | it("should be a function", () => { 61 | expect(typeof AABox2d.initSizeFromOrigin).to.equal("function") 62 | }) 63 | }) 64 | 65 | describe("initSizeFromLocation", () => { 66 | it("should be a function", () => { 67 | expect(typeof AABox2d.initSizeFromLocation).to.equal("function") 68 | }) 69 | }) 70 | 71 | describe("initCenterExtents", () => { 72 | it("should be a function", () => { 73 | expect(typeof AABox2d.initCenterExtents).to.equal("function") 74 | }) 75 | }) 76 | 77 | describe("isEmpty", () => { 78 | it("should be a function", () => { 79 | expect(typeof AABox2d.isEmpty).to.equal("function") 80 | }) 81 | }) 82 | 83 | describe("isInfinite", () => { 84 | it("should be a function", () => { 85 | expect(typeof AABox2d.isInfinite).to.equal("function") 86 | }) 87 | }) 88 | 89 | describe("equals", () => { 90 | it("should be a function", () => { 91 | expect(typeof AABox2d.equals).to.equal("function") 92 | }) 93 | }) 94 | 95 | describe("getSize", () => { 96 | it("should be a function", () => { 97 | expect(typeof AABox2d.getSize).to.equal("function") 98 | }) 99 | }) 100 | 101 | describe("getExtents", () => { 102 | it("should be a function", () => { 103 | expect(typeof AABox2d.getExtents).to.equal("function") 104 | }) 105 | }) 106 | 107 | describe("getCenter", () => { 108 | it("should be a function", () => { 109 | expect(typeof AABox2d.getCenter).to.equal("function") 110 | }) 111 | }) 112 | 113 | describe("expand", () => { 114 | it("should be a function", () => { 115 | expect(typeof AABox2d.expand).to.equal("function") 116 | }) 117 | }) 118 | 119 | describe("area", () => { 120 | it("should be a function", () => { 121 | expect(typeof AABox2d.area).to.equal("function") 122 | }) 123 | }) 124 | 125 | describe("intersection", () => { 126 | it("should be a function", () => { 127 | expect(typeof AABox2d.intersection).to.equal("function") 128 | }) 129 | }) 130 | 131 | describe("overlaps", () => { 132 | it("should be a function", () => { 133 | expect(typeof AABox2d.overlaps).to.equal("function") 134 | }) 135 | }) 136 | 137 | describe("contains", () => { 138 | it("should be a function", () => { 139 | expect(typeof AABox2d.contains).to.equal("function") 140 | }) 141 | }) 142 | 143 | describe("containsPt", () => { 144 | it("should be a function", () => { 145 | expect(typeof AABox2d.containsPt).to.equal("function") 146 | }) 147 | }) 148 | 149 | describe("encapsulatePt", () => { 150 | it("should be a function", () => { 151 | expect(typeof AABox2d.encapsulatePt).to.equal("function") 152 | }) 153 | }) 154 | 155 | describe("translate", () => { 156 | it("should be a function", () => { 157 | expect(typeof AABox2d.translate).to.equal("function") 158 | }) 159 | }) 160 | 161 | describe("transformMat2", () => { 162 | it("should be a function", () => { 163 | expect(typeof AABox2d.transformMat2).to.equal("function") 164 | }) 165 | }) 166 | 167 | describe("transformMat2d", () => { 168 | it("should be a function", () => { 169 | expect(typeof AABox2d.transformMat2d).to.equal("function") 170 | }) 171 | }) 172 | }) 173 | -------------------------------------------------------------------------------- /src/shapes/rect.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | import * as AABox2d from "../core/aabox2d" 4 | import BaseShape from "./base-shape.js" 5 | import * as Point2d from "../core/point2d" 6 | 7 | /** 8 | * @typedef {object} RectOptions 9 | * @property {number} [width=0] Width of the rect in world space coords 10 | * @property {number} [height=0] Height of the rect in world space coords 11 | */ 12 | 13 | /** 14 | * @class Class for defining a rectangle shape 15 | * @extends {BaseShape} 16 | */ 17 | export default class Rect extends BaseShape { 18 | /** 19 | * Creates a new rectangle shape 20 | * @param {RectOptions} [opts] 21 | * @return {Rect} 22 | */ 23 | constructor(opts) { 24 | super(opts) 25 | this._width = 0 26 | this._height = 0 27 | if (typeof opts.width !== "undefined") { 28 | this.width = opts.width 29 | } 30 | if (typeof opts.height !== "undefined") { 31 | this.height = opts.height 32 | } 33 | } 34 | 35 | /** 36 | * Gets the untransformed width/height of the rect 37 | * @return {Vec2d} Width/height of the rect 38 | */ 39 | getDimensions() { 40 | return [this._width, this._height] 41 | } 42 | 43 | /** 44 | * Sets the width of the rectangle 45 | * @param {number} width Width of the rect in world-space coordinates 46 | * @return {Rect} this 47 | * @fires {Shape#geomChanged} 48 | * @throws {Error} If width is not a valid number 49 | */ 50 | set width(width) { 51 | if (typeof width !== "number") { 52 | throw new Error("Width must be a number") 53 | } 54 | 55 | if (width !== this._width) { 56 | const prev = this._width 57 | this._width = width 58 | this._geomDirty = true // dirty needs to be set before firing event 59 | 60 | this.fire("changed:geom", { 61 | attr: "width", 62 | prevVal: prev, 63 | currVal: this._width 64 | }) 65 | } 66 | return this 67 | } 68 | 69 | /** 70 | * Gets the current untransformed width of the rect 71 | * @return {number} Width in world-space units 72 | */ 73 | get width() { 74 | return this._width 75 | } 76 | 77 | /** 78 | * Sets the height of the rectangle 79 | * @param {number} height Height of the rect in world-space units 80 | * @return {Rect} this 81 | * @fires {Shape#geomChanged} 82 | * @throws {Error} If height is not a valid number 83 | */ 84 | set height(height) { 85 | if (typeof height !== "number") { 86 | throw new Error("Height must be a number") 87 | } 88 | 89 | if (height !== this._height) { 90 | const prev = this._height 91 | this._height = height 92 | this._geomDirty = true // dirty needs to be set before firing event 93 | 94 | this.fire("changed:geom", { 95 | attr: "height", 96 | prevVal: prev, 97 | currVal: this._height 98 | }) 99 | } 100 | return this 101 | } 102 | 103 | /** 104 | * Gets the current untransformed height of the rect 105 | * @return {number} Height in world-space units 106 | */ 107 | get height() { 108 | return this._height 109 | } 110 | 111 | /** 112 | * Called when the bounding box requires updating 113 | * @private 114 | * @override 115 | */ 116 | _updateAABox() { 117 | if (this._geomDirty || this._boundsOutOfDate) { 118 | AABox2d.initCenterExtents(this._aabox, Point2d.create(0, 0), [ 119 | this._width / 2, 120 | this._height / 2 121 | ]) 122 | AABox2d.transformMat2d(this._aabox, this._aabox, this.globalXform) 123 | this._geomDirty = this._boundsOutOfDate = false 124 | } 125 | } 126 | 127 | /** 128 | * Draws the rect using a 2d rendering context. Called by the BaseShape 129 | * class 130 | * @param {CanvasRenderingContext2d} ctx 2d rendering context 131 | * @override 132 | */ 133 | _draw(ctx) { 134 | ctx.setTransform(1, 0, 0, 1, 0, 0) 135 | 136 | const half_width = this.width / 2 137 | const half_height = this.height / 2 138 | 139 | const corner_pt = Point2d.create(-half_width, -half_height) 140 | Point2d.transformMat2d(corner_pt, corner_pt, this._fullXform) 141 | ctx.moveTo(corner_pt[0], corner_pt[1]) 142 | 143 | Point2d.set(corner_pt, half_width, -half_height) 144 | Point2d.transformMat2d(corner_pt, corner_pt, this._fullXform) 145 | ctx.lineTo(corner_pt[0], corner_pt[1]) 146 | 147 | Point2d.set(corner_pt, half_width, half_height) 148 | Point2d.transformMat2d(corner_pt, corner_pt, this._fullXform) 149 | ctx.lineTo(corner_pt[0], corner_pt[1]) 150 | 151 | Point2d.set(corner_pt, -half_width, half_height) 152 | Point2d.transformMat2d(corner_pt, corner_pt, this._fullXform) 153 | ctx.lineTo(corner_pt[0], corner_pt[1]) 154 | 155 | ctx.closePath() 156 | } 157 | 158 | /** 159 | * Called to convert the shape to a serializable JSON object 160 | * @return {object} 161 | * @override 162 | */ 163 | toJSON() { 164 | return Object.assign( 165 | { 166 | type: "Rect", 167 | width: this.width, 168 | height: this.height 169 | }, 170 | super.toJSON() 171 | ) 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/core/point2d.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | import { glMatrix } from "gl-matrix" 4 | import Vec2d from "./vec2d" 5 | 6 | const X = 0 7 | const Y = 1 8 | 9 | /** 10 | * Sets the coordinates of a 2D point 11 | * @param {Point2d} out Point to set 12 | * @param {Number} [x = 0] X coordinate 13 | * @param {Number} [y = 0] Y coordinate 14 | * @return {Point2d} Point referenced by out arg 15 | */ 16 | export function set(out, x = 0, y = 0) { 17 | out[X] = x 18 | out[Y] = y 19 | return out 20 | } 21 | 22 | /** 23 | * Creates a new Point2d object 24 | * @param {Number} [x = 0] X coordinate 25 | * @param {Number} [y = 0] Y coordinate 26 | * @return {Point2d} 27 | */ 28 | export function create(x = 0, y = 0) { 29 | const out = new glMatrix.ARRAY_TYPE(2) 30 | return set(out, x, y) 31 | } 32 | 33 | /** 34 | * Creates a new Point2d object with coordinates initialized from an existing point. 35 | * @param {Point2d} p 36 | * @return {Point2d} 37 | */ 38 | export function clone(p) { 39 | const out = new glMatrix.ARRAY_TYPE(2) 40 | out[X] = p[X] 41 | out[Y] = p[Y] 42 | return out 43 | } 44 | 45 | /** 46 | * Copies the coordinates of one point to another. 47 | * @param {Point2d} out The point to copy to 48 | * @param {Point2d} p The point to copy from 49 | * @return {Point2d} Point referenced by out arg 50 | */ 51 | export function copy(out, p) { 52 | out[X] = p[X] 53 | out[Y] = p[Y] 54 | return out 55 | } 56 | 57 | /** 58 | * Creates a new point from coordinates 59 | * @param {Number} x X coordinate 60 | * @param {Number} y Y coordinate 61 | * @return {Point2d} New point 62 | */ 63 | export function initFromValues(x, y) { 64 | return create(x, y) 65 | } 66 | 67 | /** 68 | * Adds a 2d offset to an existing point 69 | * @param {Point2d} out Point receiving the operation result 70 | * @param {Point2d} pt Existing point 71 | * @param {Vec2d} v Vector describing the offset 72 | * @return {Point2d} Point referenced by out arg 73 | */ 74 | export function addVec2(out, pt, v) { 75 | return Vec2d.add(out, pt, v) 76 | } 77 | 78 | /** 79 | * Calculates the difference between two points 80 | * @param {Vec2d} out Vector receiving operation result 81 | * @param {Point2d} pt1 82 | * @param {Point2d} pt2 83 | * @return {Vec2d} Vector referenced by out arg 84 | */ 85 | export function sub(out, pt1, pt2) { 86 | return Vec2d.sub(out, pt1, pt2) 87 | } 88 | 89 | /** 90 | * Performs an 2x2 matrix multiplication on a point 91 | * @param {Point2d} out Point receiving operation result 92 | * @param {Point2d} p 93 | * @param {Mat2} m 2x2 matrix 94 | * @return {Point2d} Point referenced by out arg 95 | */ 96 | export function transformMat2(out, p, m) { 97 | return Vec2d.transformMat2(out, p, m) 98 | } 99 | 100 | /** 101 | * Performs a 2x3 matrix multiplication on a point. 102 | * A 2x3 matrix is ultimately a 2x2 matrix with a 103 | * translation component 104 | * @param {Point2d} out Point receiving operation result 105 | * @param {Point2d} p 106 | * @param {Mat2d} m 2x3 matrix 107 | * @return {Point2d} Point referenced by out arg 108 | */ 109 | export function transformMat2d(out, p, m) { 110 | return Vec2d.transformMat2d(out, p, m) 111 | } 112 | 113 | /** 114 | * Creates a string representation of a point 115 | * @param {Point2d} p 116 | * @return {string} 117 | */ 118 | export function str(p) { 119 | return `point2d(${p[0]} , ${p[1]})` 120 | } 121 | 122 | /** 123 | * Computes the distance between two 2d points 124 | * @param {Point2d} p1 125 | * @param {Point2d} p2 126 | * @return {Number} 127 | */ 128 | export function distance(p1, p2) { 129 | return Vec2d.distance(p1, p2) 130 | } 131 | 132 | export const dist = distance 133 | 134 | /** 135 | * Returns the squared distance between two points. 136 | * This is a cheaper operation than the true distance 137 | * calculation. 138 | * @param {Point2d} p1 139 | * @param {Point2d} p2 140 | * @return {Number} distance^2 141 | */ 142 | export function squaredDistance(p1, p2) { 143 | return Vec2d.squaredDistance(p1, p2) 144 | } 145 | 146 | export const sqrDist = squaredDistance 147 | 148 | /** 149 | * Calculates the point linearly interpolated 150 | * between two points according to the relative operator t 151 | * [t == 0 = p1 & t == 1 = p2] 152 | * @param {Point2d} out Point receiving result of operation 153 | * @param {Point2d} p1 Start point (t = 0) 154 | * @param {Point2d} p2 End point (t = 1) 155 | * @param {[type]} t Interpolate parameter [0, 1] 156 | * @return {Point2d} Point referenced by out arg 157 | */ 158 | export function lerp(out, p1, p2, t) { 159 | return Vec2d.lerp(out, p1, p2, t) 160 | } 161 | 162 | /** 163 | * Returns whether two points are relatively equal 164 | * @param {Point2d} a 165 | * @param {Point2d} b 166 | * @param {Number} [epsilon=null] Optional epsilon value to use for the comparison. If null, uses 167 | * the globally-configured epsilon. 168 | * @return {Boolean} Returns true if two point are relatively equal, false otherwise 169 | */ 170 | export function equals(a, b, epsilon = null) { 171 | return Vec2d.equals(a, b, epsilon) 172 | } 173 | 174 | /** 175 | * Returns whether two points are exactly equal. 176 | * @param {Point2d} a 177 | * @param {Point2d} b 178 | * @return {Boolean} Returns true if the two points are exactly equal, false otherwise 179 | */ 180 | export function exactEquals(a, b) { 181 | return Vec2d.exactEquals(a, b) 182 | } 183 | -------------------------------------------------------------------------------- /src/style/fill-style.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | import ColorRGBA, { createEventedColorRGBAClass } from "./color-rgba" 4 | import aggregation from "../util/aggregation" 5 | 6 | /** 7 | * @typedef {object} FillStyleOptions 8 | * @property {string} [fillColor="black"] Fill color defined as a string 9 | * @property {number} [fillOpacity=1] Opacity of the fill color. This supersedes any opacity inherent in fillColor 10 | */ 11 | 12 | /** 13 | * Manages the fill style of a 2d rendering context. Can be used as a mixin or base class 14 | * @class 15 | * @mixin 16 | */ 17 | export default class FillStyle { 18 | /** 19 | * Creates a new fill style object 20 | * @param {FillStyleOptions} [opts] 21 | * @return {FillStyle} 22 | */ 23 | constructor(opts) { 24 | // TODO(croot): support gradients and patterns 25 | this.initializer(opts) 26 | } 27 | 28 | /** 29 | * initializes the fill style object from an options object 30 | * @param {FillStyleOptions} [opts] 31 | * @private 32 | */ 33 | _initFillStyleFromOptions(opts) { 34 | if (opts) { 35 | if (typeof opts.fillColor !== "undefined") { 36 | this.fillColor = opts.fillColor 37 | } 38 | if (typeof opts.fillOpacity !== "undefined") { 39 | this.fillOpacity = opts.fillOpacity 40 | } 41 | } 42 | } 43 | 44 | /** 45 | * Initializer method to initialize a fill style. Used for both initializing 46 | * via base-class and mixin hierarchy. 47 | * @param {FillStyleOptions} [opts] 48 | */ 49 | initializer(opts) { 50 | this._fillColor = new ColorRGBA("black") 51 | this._initFillStyleFromOptions(opts) 52 | } 53 | 54 | /** 55 | * Sets the fill color 56 | * @param {string} fillColor Color as a string, "rgb()", "rgba()", "#......", or a color keyword (i.e. "black") 57 | * @return {FillStyle} 58 | */ 59 | set fillColor(fillColor) { 60 | this._fillColor.value = fillColor 61 | return this 62 | } 63 | 64 | /** 65 | * Gets the fill color of the style 66 | * @return {string} 67 | */ 68 | get fillColor() { 69 | return this._fillColor.value 70 | } 71 | 72 | /** 73 | * Sets the opacity of the fill style 74 | * @param {number} opacity [0,1] 75 | * @return {FillStyle} 76 | */ 77 | set fillOpacity(opacity) { 78 | this._fillColor.opacity = opacity 79 | return this 80 | } 81 | 82 | /** 83 | * Gets the current opacity of the fill style [0,1] 84 | * @return {number} Opacity in the range [0,1] 85 | */ 86 | get fillOpacity() { 87 | return this._fillColor.opacity 88 | } 89 | 90 | /** 91 | * Sets the fill color of the style defined as a 32-bit int 92 | * @param {number} packedFillColor Color value as a 32-bit int (i.e. 0xFFFFFFFF) 93 | * @return {FillStyle} 94 | */ 95 | set packedFillColor(packedFillColor) { 96 | this._fillColor.packedValue = packedFillColor 97 | return this 98 | } 99 | 100 | /** 101 | * Gets the current value of the color of the fill style as a 32-bit int 102 | * @return {number} i.e. 0xFFFFFFFF 103 | */ 104 | get packedFillColor() { 105 | return this._fillColor.packedValue 106 | } 107 | 108 | /** 109 | * Returns true if the fill style is visible, i.e. it has an opacity > 0 110 | * @return {Boolean} 111 | */ 112 | isFillVisible() { 113 | return this._fillColor.opacity > 0 114 | } 115 | 116 | /** 117 | * Returns true if the fill style is transparent in any way, i.e. opacity < 1 118 | * @return {Boolean} 119 | */ 120 | isTransparent() { 121 | return this._fillColor.isTransparent() 122 | } 123 | 124 | /** 125 | * Sets the fill style state of a 2d rendering context 126 | * @param {CanvasRenderingContext2D} ctx 127 | */ 128 | setFillCtx(ctx) { 129 | ctx.fillStyle = this.fillColor 130 | } 131 | 132 | /** 133 | * Copies the properties of one fill style to another 134 | * @param {FillStyle} srcStyle FillStyle object to copy from 135 | * @param {FillStyle} dstStyle FillStyle object to copy to 136 | */ 137 | static copyFillStyle(srcStyle, dstStyle) { 138 | if (typeof srcStyle.packedFillColor === "undefined") { 139 | if (typeof srcStyle.fillColor !== "undefined") { 140 | dstStyle.fillColor = srcStyle.fillColor 141 | } 142 | if (typeof srcStyle.fillOpacity !== "undefined") { 143 | dstStyle.fillOpacity = srcStyle.fillOpacity 144 | } 145 | } else { 146 | dstStyle.packedFillColor = srcStyle.packedFillColor 147 | } 148 | } 149 | 150 | /** 151 | * Comparison operator between two FillStyle objects. This is primarily 152 | * used for sorting to minimize context switching of a 2d renderer 153 | * @param {FillStyle} fillStyleA 154 | * @param {FillStyle} fillStyleB 155 | * @return {number} Returns < 0 if fillStyleA < fillStyleB, > 0 if fillStyleA > fillStyleB, or 0 if they are equal. 156 | */ 157 | static compareFillStyle(fillStyleA, fillStyleB) { 158 | const valA = fillStyleA.isFillVisible() 159 | const valB = fillStyleB.isFillVisible() 160 | if (valA !== valB) { 161 | return valA - valB 162 | } 163 | return fillStyleA.packedFillColor - fillStyleB.packedFillColor 164 | } 165 | 166 | /** 167 | * Returns a json object of a FillStyle object 168 | * @param {FillStyle} fillStyleObj 169 | * @return {{fillColor: string}} 170 | */ 171 | static toJSON(fillStyleObj) { 172 | return { 173 | fillColor: fillStyleObj.fillColor 174 | } 175 | } 176 | } 177 | 178 | /** 179 | * Creates a new fill style class that fires events whenever the style 180 | * is modified. 181 | * @param {string} eventName Event type to fire when fill style is modified 182 | * @return {function} New class constructor function 183 | */ 184 | export function createEventedFillStyleMixin(eventName) { 185 | /** 186 | * Evented fill color class to handle fill color modifications 187 | * @type {ColorRGBA} 188 | */ 189 | const FillColorClass = createEventedColorRGBAClass(eventName, "fillColor") 190 | 191 | /** 192 | * @mixin New evented fill style mixin. Will fire events whenever 193 | * the fill color is modified 194 | */ 195 | return aggregation( 196 | null, 197 | FillStyle, 198 | class EventedFillStyle { 199 | initializer(opts) { 200 | this._fillColor = new FillColorClass("red", this) 201 | this._initFillStyleFromOptions(opts) 202 | } 203 | } 204 | ) 205 | } 206 | -------------------------------------------------------------------------------- /src/interactions/vert-editable-shape.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-magic-numbers */ 2 | "use strict" 3 | 4 | import * as AABox2d from "../core/aabox2d" 5 | import * as Point2d from "../core/point2d" 6 | import Mat2d from "../core/mat2d" 7 | import Vec2d from "../core/vec2d" 8 | import BaseShape from "../shapes/base-shape" 9 | 10 | export default class VertEditableShape extends BaseShape { 11 | constructor(baseVertShape, opts) { 12 | super(opts) 13 | this._baseVertShape = baseVertShape 14 | this._vertRadius = 4 15 | this._transformedVerts = [] 16 | this._baseaabox = AABox2d.create() 17 | this._worldToScreenMatrix = Mat2d.create() 18 | } 19 | 20 | _updateAABox(worldToScreenMatrix) { 21 | const aabox = this._baseVertShape.aabox 22 | if ( 23 | !AABox2d.equals(aabox, this._baseaabox) || 24 | !Mat2d.equals(worldToScreenMatrix, this._worldToScreenMatrix) 25 | ) { 26 | AABox2d.copy(this._baseaabox, aabox) 27 | Mat2d.copy(this._worldToScreenMatrix, worldToScreenMatrix) 28 | AABox2d.transformMat2d( 29 | this._aabox, 30 | this._baseaabox, 31 | this._worldToScreenMatrix 32 | ) 33 | const pad = this._vertRadius + this.strokeWidth 34 | AABox2d.expand(this._aabox, this._aabox, [pad, pad]) 35 | } 36 | } 37 | 38 | containsPoint(screenPt, worldPt, worldToScreenMatrix, ctx) { 39 | // Should we update here, or is it safe to 40 | // say that this is stateful, meaning a render 41 | // should have been performed beforehand which 42 | // would've updated its state 43 | const rtnObj = { 44 | hit: false, 45 | controlIndex: -1 46 | } 47 | 48 | this._updateAABox(worldToScreenMatrix) 49 | if (this.visible && AABox2d.containsPt(this._aabox, screenPt)) { 50 | const aabox = AABox2d.create() 51 | const pad = this._vertRadius + this.strokeWidth / 2 52 | const extents = [pad, pad] 53 | 54 | let i = 0 55 | for (i = 0; i < this._transformedVerts.length; i += 1) { 56 | AABox2d.initCenterExtents(aabox, this._transformedVerts[i], extents) 57 | if (AABox2d.containsPt(aabox, screenPt)) { 58 | rtnObj.hit = true 59 | rtnObj.controlIndex = i 60 | break 61 | } 62 | } 63 | 64 | if (!rtnObj.hit) { 65 | const tmpPt = Point2d.create() 66 | const tmpVec = Vec2d.create() 67 | const radius = ctx.lineWidth * 1.5 68 | Vec2d.set(extents, radius, radius) 69 | for (i = 0; i < this._transformedVerts.length - 1; i += 1) { 70 | Point2d.sub( 71 | tmpVec, 72 | this._transformedVerts[i + 1], 73 | this._transformedVerts[i] 74 | ) 75 | Vec2d.scale(tmpVec, tmpVec, 0.5) 76 | Point2d.addVec2(tmpPt, this._transformedVerts[i], tmpVec) 77 | AABox2d.initCenterExtents(aabox, tmpPt, extents) 78 | if (AABox2d.containsPt(aabox, screenPt)) { 79 | rtnObj.hit = true 80 | rtnObj.controlIndex = this._transformedVerts.length + i 81 | break 82 | } 83 | } 84 | 85 | if (i > 0 && i === this._transformedVerts.length - 1) { 86 | Point2d.sub( 87 | tmpVec, 88 | this._transformedVerts[0], 89 | this._transformedVerts[i] 90 | ) 91 | Vec2d.scale(tmpVec, tmpVec, 0.5) 92 | Point2d.addVec2(tmpPt, this._transformedVerts[i], tmpVec) 93 | AABox2d.initCenterExtents(aabox, tmpPt, extents) 94 | if (AABox2d.containsPt(aabox, screenPt)) { 95 | rtnObj.hit = true 96 | rtnObj.controlIndex = this._transformedVerts.length + i 97 | } 98 | } 99 | } 100 | } 101 | 102 | return rtnObj 103 | } 104 | 105 | renderBounds(ctx, worldToScreenMatrix, boundsStrokeStyle) { 106 | // we're storing our AABox in screen space here, so worldToScreenMatrix is 107 | // unused 108 | this._updateAABox(worldToScreenMatrix) 109 | ctx.save() 110 | ctx.setTransform(1, 0, 0, 1, 0, 0) 111 | boundsStrokeStyle.setStrokeCtx(ctx) 112 | const center = Point2d.create() 113 | const extents = Vec2d.create() 114 | AABox2d.getCenter(center, this._aabox) 115 | AABox2d.getExtents(extents, this._aabox) 116 | ctx.beginPath() 117 | ctx.rect( 118 | center[0] - extents[0], 119 | center[1] - extents[1], 120 | extents[0] * 2, 121 | extents[1] * 2 122 | ) 123 | ctx.stroke() 124 | ctx.restore() 125 | } 126 | 127 | render(ctx) { 128 | ctx.save() 129 | 130 | ctx.setTransform(1, 0, 0, 1, 0, 0) 131 | const objToScreenMatrix = this._baseVertShape._fullXform 132 | const verts = this._baseVertShape.vertsRef 133 | this._transformedVerts = new Array(verts.length) 134 | const tmpPt = Point2d.create() 135 | const tmpVec = Vec2d.create() 136 | 137 | ctx.beginPath() 138 | let i = 0 139 | this._transformedVerts[i] = Point2d.create() 140 | Point2d.transformMat2d( 141 | this._transformedVerts[i], 142 | verts[i], 143 | objToScreenMatrix 144 | ) 145 | 146 | const radius = Math.max(ctx.lineWidth * 1.5, 2.5) 147 | for (i = 0; i < verts.length - 1; i += 1) { 148 | this._transformedVerts[i + 1] = Point2d.create() 149 | Point2d.transformMat2d( 150 | this._transformedVerts[i + 1], 151 | verts[i + 1], 152 | objToScreenMatrix 153 | ) 154 | Point2d.sub( 155 | tmpVec, 156 | this._transformedVerts[i + 1], 157 | this._transformedVerts[i] 158 | ) 159 | Vec2d.scale(tmpVec, tmpVec, 0.5) 160 | Point2d.addVec2(tmpPt, this._transformedVerts[i], tmpVec) 161 | 162 | ctx.moveTo(tmpPt[0] + radius, tmpPt[1]) 163 | ctx.arc(tmpPt[0], tmpPt[1], radius, 0, Math.TWO_PI) 164 | } 165 | 166 | if (i > 0) { 167 | Point2d.sub(tmpVec, this._transformedVerts[0], this._transformedVerts[i]) 168 | Vec2d.scale(tmpVec, tmpVec, 0.5) 169 | Point2d.addVec2(tmpPt, this._transformedVerts[i], tmpVec) 170 | ctx.moveTo(tmpPt[0] + radius, tmpPt[1]) 171 | ctx.arc(tmpPt[0], tmpPt[1], radius, 0, Math.TWO_PI) 172 | 173 | // TODO(croot): Is this appropriate? Can the fill/stroke style 174 | // be cross compatible? What about gradients/patterns? 175 | // We can probably safely assume no gradients/patterns at 176 | // this point 177 | ctx.fillStyle = ctx.strokeStyle 178 | ctx.fill() 179 | } 180 | 181 | ctx.beginPath() 182 | 183 | this._transformedVerts.forEach(vert => { 184 | ctx.moveTo(vert[0] + this._vertRadius, vert[1]) 185 | ctx.arc(vert[0], vert[1], this._vertRadius, 0, Math.TWO_PI) 186 | }) 187 | 188 | if (this.isFillVisible()) { 189 | this.setFillCtx(ctx) 190 | ctx.fill() 191 | } 192 | 193 | if (this.isStrokeVisible()) { 194 | this.setStrokeCtx(ctx) 195 | ctx.stroke() 196 | } 197 | 198 | ctx.restore() 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/interactions/interact-utils.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-magic-numbers */ 2 | "use strict" 3 | 4 | import * as Point2d from "../core/point2d" 5 | import Mat2d from "../core/mat2d" 6 | import Vec2d from "../core/vec2d" 7 | 8 | function rotateOBBox(shape, parentShape, selectedInfo, screenPos, worldPos) { 9 | const pt = Point2d.create() 10 | const scale = Vec2d.create() 11 | Mat2d.svd(pt, scale, null, selectedInfo.objectToWorldMatrix) 12 | const startDir = Vec2d.create() 13 | Point2d.sub(startDir, selectedInfo.startWorldPos, pt) 14 | Vec2d.normalize(startDir, startDir) 15 | const dir = pt 16 | Point2d.sub(dir, worldPos, pt) 17 | Vec2d.normalize(dir, dir) 18 | let angle = Vec2d.angle(startDir, dir) 19 | 20 | if (selectedInfo.keys.shiftKey) { 21 | angle = Math.round(angle / Math.QUATER_PI) * Math.QUATER_PI 22 | } 23 | 24 | // if (!camera.isYFlipped()) { 25 | // angle *= -1 26 | // } 27 | 28 | parentShape.setRotation(selectedInfo.startLocalRot + Math.RAD_TO_DEG * angle) 29 | } 30 | 31 | export function transformXformShape( 32 | shape, 33 | selectedInfo, 34 | screenPos, 35 | worldPos, 36 | camera 37 | ) { 38 | const parentShape = shape.parent 39 | const objPos = Point2d.create() 40 | const deltaPos = Point2d.create() 41 | const deltaDims = Point2d.create() 42 | 43 | if (selectedInfo.rotate) { 44 | rotateOBBox(shape, parentShape, selectedInfo, screenPos, worldPos, camera) 45 | return 46 | } 47 | 48 | const uniformScale = 49 | selectedInfo.keys.shiftKey || selectedInfo.uniformScaleOnly 50 | const centerScale = selectedInfo.keys.altKey || selectedInfo.centerScaleOnly 51 | 52 | // get the position of the shape at start of transform 53 | const pt = Point2d.create() 54 | Mat2d.svd(pt, null, null, selectedInfo.objectToWorldMatrix) 55 | 56 | // get the mouse delta in world space 57 | Vec2d.sub(deltaPos, worldPos, selectedInfo.startWorldPos) 58 | 59 | if (uniformScale && selectedInfo.controlIndex < 4) { 60 | const xAxisDir = [ 61 | selectedInfo.objectToWorldMatrix[0], 62 | selectedInfo.objectToWorldMatrix[1] 63 | ] 64 | const yAxisDir = [ 65 | selectedInfo.objectToWorldMatrix[2], 66 | selectedInfo.objectToWorldMatrix[3] 67 | ] 68 | const diagDir = Vec2d.create() 69 | 70 | if (selectedInfo.controlIndex < 2) { 71 | Vec2d.negate(xAxisDir, xAxisDir) 72 | } 73 | if (selectedInfo.controlIndex % 2 === 0) { 74 | Vec2d.negate(yAxisDir, yAxisDir) 75 | } 76 | 77 | Vec2d.normalize(xAxisDir, xAxisDir) 78 | Vec2d.normalize(yAxisDir, yAxisDir) 79 | 80 | Vec2d.add(diagDir, xAxisDir, yAxisDir) 81 | Vec2d.normalize(diagDir, diagDir) 82 | 83 | const cross = Vec2d.cross2d(deltaPos, diagDir) 84 | let axisToUse = null 85 | if (selectedInfo.controlIndex === 0 || selectedInfo.controlIndex === 3) { 86 | axisToUse = yAxisDir 87 | if (cross < 0) { 88 | axisToUse = xAxisDir 89 | } 90 | } else { 91 | axisToUse = xAxisDir 92 | if (cross < 0) { 93 | axisToUse = yAxisDir 94 | } 95 | } 96 | let mindist = Vec2d.dot(deltaPos, axisToUse) 97 | mindist = Math.sign(mindist) * Math.sqrt(2 * mindist * mindist) 98 | Vec2d.scale(deltaPos, diagDir, mindist) 99 | Point2d.addVec2(worldPos, selectedInfo.startWorldPos, deltaPos) 100 | } 101 | 102 | // first convert world point to object space 103 | Point2d.copy(objPos, worldPos) 104 | Point2d.transformMat2d(objPos, objPos, selectedInfo.worldToObjectMatrix) 105 | 106 | // get the mouse delta in object space and multipy by the 107 | // scale of the selected object at the start of the transform 108 | // to get the scale delta in object space 109 | Vec2d.sub(deltaDims, objPos, selectedInfo.startObjectPos) 110 | 111 | Point2d.copy(pt, selectedInfo.startLocalPos) 112 | 113 | // now determine the transform direction depending 114 | // on which control vertex of the object-oriented bounds 115 | // was selected 116 | let xScale = 0 117 | let yScale = 0 118 | if (selectedInfo.controlIndex < 4) { 119 | // dragging a corner vertex 120 | xScale = selectedInfo.controlIndex < 2 ? -1 : 1 121 | yScale = selectedInfo.controlIndex % 2 === 0 ? -1 : 1 122 | 123 | // can translate based on the mouse delta in world space 124 | // This is done to offset the scale, which is done at 125 | // the shape's center. This ultimately acts as a pivot 126 | // for the transformation. Only do this if the alt key 127 | // isn't pressed 128 | if (!centerScale) { 129 | Point2d.addVec2(pt, pt, Vec2d.scale(deltaPos, deltaPos, 0.5)) 130 | } 131 | 132 | parentShape.setPosition(pt) 133 | } else { 134 | // dragging a side vertex, which means we only scale in 135 | // one dimension, rather than 2. So we need to figure 136 | // out that direction based on the orientation of the 137 | // shape 138 | const idx = selectedInfo.controlIndex - 4 139 | const axisDir = Vec2d.create() 140 | if (idx % 2 === 0) { 141 | // scaling in the object's X direction 142 | Vec2d.set( 143 | axisDir, 144 | selectedInfo.objectToWorldMatrix[0], 145 | selectedInfo.objectToWorldMatrix[1] 146 | ) 147 | yScale = 0 148 | xScale = idx < 2 ? -1 : 1 149 | if (uniformScale) { 150 | yScale = xScale 151 | deltaDims[1] = deltaDims[0] 152 | } 153 | } else { 154 | // scaling in the object's Y direction 155 | Vec2d.set( 156 | axisDir, 157 | selectedInfo.objectToWorldMatrix[2], 158 | selectedInfo.objectToWorldMatrix[3] 159 | ) 160 | xScale = 0 161 | yScale = idx < 2 ? -1 : 1 162 | if (uniformScale) { 163 | xScale = yScale 164 | deltaDims[0] = deltaDims[1] 165 | } 166 | } 167 | 168 | // now find the pivot offset for the axis-aligned scale 169 | if (!centerScale) { 170 | Vec2d.normalize(axisDir, axisDir) 171 | Vec2d.scale(axisDir, axisDir, Vec2d.dot(deltaPos, axisDir)) 172 | Point2d.addVec2(pt, pt, Vec2d.scale(axisDir, axisDir, 0.5)) 173 | } 174 | 175 | parentShape.setPosition(pt) 176 | } 177 | 178 | if (centerScale) { 179 | xScale *= 2 180 | yScale *= 2 181 | } 182 | 183 | // perform the scale 184 | parentShape.setScale([ 185 | selectedInfo.startLocalScale[0] * 186 | (1 + (xScale * deltaDims[0]) / selectedInfo.shapeWidth), 187 | selectedInfo.startLocalScale[1] * 188 | (1 + (yScale * deltaDims[1]) / selectedInfo.shapeHeight) 189 | ]) 190 | } 191 | 192 | export function translateShape( 193 | shape, 194 | selectedInfo, 195 | screenPos, 196 | worldPos, 197 | camera 198 | ) { 199 | const diff = Vec2d.create() 200 | const pt = Point2d.create() 201 | Mat2d.svd(pt, null, null, selectedInfo.objectToWorldMatrix) 202 | if (selectedInfo.keys.shiftKey) { 203 | Point2d.sub(diff, screenPos, selectedInfo.startPos) 204 | let angle = Math.atan2(diff[1], diff[0]) 205 | angle = Math.round(angle / Math.QUATER_PI) * Math.QUATER_PI 206 | const transformDir = [Math.cos(angle), Math.sin(angle)] 207 | Vec2d.scale(diff, transformDir, Vec2d.dot(diff, transformDir)) 208 | Vec2d.transformMat2(diff, diff, camera.screenToWorldMatrix) 209 | } else { 210 | Vec2d.sub(diff, worldPos, selectedInfo.startWorldPos) 211 | } 212 | Point2d.addVec2(pt, selectedInfo.startLocalPos, diff) 213 | 214 | shape.setPosition(pt) 215 | } 216 | 217 | export function translateVert(shape, selectedInfo, screenPos, worldPos) { 218 | const parentShape = shape.parent 219 | 220 | // get the position of the shape at start of transform 221 | // const pt = Point2d.create() 222 | // Mat2d.svd(pt, null, null, selectedInfo.objectToWorldMatrix) 223 | 224 | // get the mouse delta in world space 225 | // Vec2d.sub(deltaPos, worldPos, selectedInfo.startWorldPos) 226 | 227 | // first convert world point to object space 228 | // Point2d.copy(objPos, worldPos) 229 | // Point2d.transformMat2d(objPos, objPos, selectedInfo.worldToObjectMatrix) 230 | 231 | // get the diff 232 | // const diff = objPos 233 | // Point2d.sub(diff, objPos, selectedInfo.startObjectPos) 234 | 235 | const numVerts = parentShape.numVerts 236 | if (selectedInfo.controlIndex >= numVerts) { 237 | const idx1 = Math.min(selectedInfo.controlIndex - numVerts, numVerts - 1) 238 | const idx2 = idx1 === numVerts - 1 ? 0 : idx1 + 1 239 | const pt = Point2d.create() 240 | const pt1 = Point2d.create() 241 | const pt2 = Point2d.create() 242 | const vec = Vec2d.create() 243 | const verts = parentShape.vertsRef 244 | const xform = parentShape.globalXform 245 | Point2d.transformMat2d(pt1, verts[idx1], xform) 246 | Point2d.transformMat2d(pt2, verts[idx2], xform) 247 | Point2d.sub(vec, pt2, pt1) 248 | Vec2d.scale(vec, vec, 0.5) 249 | Point2d.addVec2(pt, pt1, vec) 250 | selectedInfo.controlIndex = parentShape.insertVert(idx1 + 1, pt) 251 | } else { 252 | parentShape.setVertPosition(selectedInfo.controlIndex, worldPos) 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /src/util/event-handler.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | /** 4 | @typedef EventObject 5 | @type {object} 6 | @property {string} type - the type of the event 7 | @property {string} target - the target object that fired the event 8 | / 9 | 10 | /** 11 | * This callback is displayed as a global member. 12 | * @callback EventCallback 13 | * @param {EventObject} Event object describing the event being fired 14 | */ 15 | 16 | /** 17 | * Recursively fires hierarchical events from an handler's registered 18 | * event types. For example, if an event is registered as "changed:color", 19 | * both a "changed:color" and "changed" event is fired, so listeners who 20 | * only generically care if something changed on an object can be notified. 21 | * @param {Map} currMap map datastructure holding all hierarchical events and callbacks 22 | * @param {string[]} subtypes the ":" separated list of the event 23 | * @param {number} currIdx the current index of subtypes being processed 24 | * @param {Object} fireData the event object to fire 25 | * @return {number} total number of listeners called 26 | * @private 27 | */ 28 | function recursiveFire(currMap, subtypes, currIdx, fireData) { 29 | if (currIdx >= subtypes.length) { 30 | return 0 31 | } 32 | 33 | let cnt = 0 34 | let data = null 35 | data = currMap.get(subtypes[currIdx]) 36 | if (data) { 37 | cnt = recursiveFire(data[0], subtypes, currIdx + 1, fireData) 38 | data[1].forEach(listener => listener.call(this, fireData)) 39 | cnt += data[1].length 40 | } 41 | return cnt 42 | } 43 | 44 | /** 45 | * Recursively deletes specific listeners from a handlers event 46 | * data structure. For instance, if the same callback is used for both 47 | * a "changed" and a "changed:color" event, and that callback is deleted 48 | * from the "changed" event, it is also deleted from the "changed:color" event 49 | * @param {Object} currNode Current node of the map data structure being processed 50 | * @param {function[]} listeners Array of listeners to delete 51 | * @private 52 | */ 53 | function recursiveDelete(currNode, listeners) { 54 | let index = -1 55 | const subnodes = currNode[0] 56 | const nodelisteners = currNode[1] 57 | if (nodelisteners.length) { 58 | listeners.forEach(listener => { 59 | if ((index = nodelisteners.indexOf(listener)) >= 0) { 60 | nodelisteners.splice(index, 1) 61 | } 62 | }) 63 | } 64 | subnodes.forEach(node => { 65 | recursiveDelete(node, listeners) 66 | }) 67 | } 68 | 69 | /** 70 | * Validates that an input is a string or an array of strings, and if the former 71 | * returns a 1-element string array 72 | * @param {string|string[]} intype 73 | * @return {string[]} 74 | * @private 75 | */ 76 | function arrayify(intype) { 77 | let arrayToUse = intype 78 | if (typeof intype === "string") { 79 | arrayToUse = [intype] 80 | } else if (!Array.isArray(intype)) { 81 | throw new Error("Input must be an array of strings") 82 | } 83 | return arrayToUse 84 | } 85 | 86 | /** Class for managing events and listeners. Can be used as a base class or a mixin (using @see {@link aggregation}) */ 87 | export default class EventHandler { 88 | /** 89 | * Create a new event handler 90 | * @param {string|string[]} eventsToRegister initial events to register 91 | */ 92 | constructor(eventsToRegister) { 93 | this.initializer(eventsToRegister) 94 | } 95 | 96 | /** 97 | * Initializes an event handler object 98 | * @param {string|string[]} eventsToRegister events to initialize event handler with 99 | * @see {@link aggregation} 100 | */ 101 | initializer(eventsToRegister) { 102 | this._listeners = new Map() 103 | this.registerEvents(eventsToRegister) 104 | } 105 | 106 | /** 107 | * Registers new events for the event handler 108 | * @param {string|string[]} events new event(s) to register 109 | */ 110 | registerEvents(events) { 111 | if (!events) { 112 | return 113 | } 114 | 115 | let eventsToUse = arrayify(events) 116 | if (typeof events === "string") { 117 | eventsToUse = [events] 118 | } else if (!Array.isArray(events)) { 119 | throw new Error("Events must be an array of strings") 120 | } 121 | 122 | eventsToUse.forEach(event => { 123 | const subevents = event.split(":") 124 | let currMap = this._listeners 125 | for (let i = 0; i < subevents.length; i += 1) { 126 | let data = currMap.get(subevents[i]) 127 | if (!data) { 128 | data = [new Map(), []] 129 | currMap.set(subevents[i], data) 130 | } 131 | currMap = data[0] 132 | } 133 | }) 134 | } 135 | 136 | /** 137 | * Adds a new listener to a specific event or list of different events 138 | * @param {string|string[]} types event(s) this listener is listening to 139 | * @param {EventCallback} listener function to be called when events destribed by types is fired 140 | * @return {EventHandler} this 141 | */ 142 | on(types, listener) { 143 | const typesToUse = arrayify(types) 144 | typesToUse.forEach(type => { 145 | const subtypes = type.split(":") 146 | let currMap = this._listeners 147 | let data = null 148 | subtypes.forEach(subtype => { 149 | data = currMap.get(subtype) 150 | if (!data) { 151 | const keys = [] 152 | currMap.forEach((val, key) => keys.push(key)) 153 | throw new Error( 154 | `${type} is not a valid event type. The registered event types at this level are [${keys}]` 155 | ) 156 | } 157 | currMap = data[0] 158 | }) 159 | if (data[1].indexOf(listener) < 0) { 160 | data[1].push(listener) 161 | } 162 | }) 163 | 164 | return this 165 | } 166 | 167 | /** 168 | * Removes a listener from specific events 169 | * @param {string|string[]} types event(s) the listener is being removed from 170 | * @param {EventCallback} listener callback function to be cleared from the specified event types 171 | * @return {EventHandler} this 172 | */ 173 | off(types, listener) { 174 | const typesToUse = arrayify(types) 175 | let listeners = listener 176 | if (!Array.isArray(listeners)) { 177 | listeners = [listener] 178 | } 179 | typesToUse.forEach(type => { 180 | const subtypes = type.split(":") 181 | let currMap = this._listeners 182 | let data = null 183 | let i = 0 184 | for (i = 0; i < subtypes.length; i += 1) { 185 | data = currMap.get(subtypes[i]) 186 | if (!data) { 187 | break 188 | } 189 | currMap = data[0] 190 | } 191 | if (data) { 192 | recursiveDelete(data, listeners) 193 | } 194 | }) 195 | return this 196 | } 197 | 198 | /** 199 | * Sets up a lister callback to only be called once 200 | * @param {string|string[]} types event(s) the listener is listening to 201 | * @param {EventCallback} listener callback function to be called with event(s) are fired 202 | * @return {EventHandler} this 203 | */ 204 | once(types, listener) { 205 | const wrapper = data => { 206 | this.off(types, wrapper) 207 | listener.call(this, data) 208 | } 209 | this.on(types, wrapper) 210 | return this 211 | } 212 | 213 | /** 214 | * Fires a specific event and calls any listeners of that event type. 215 | * @param {string} type Event type to fire 216 | * @param {Object} data Additional data to fire with the event 217 | * @return {EventHandler} this 218 | */ 219 | fire(type, data) { 220 | const subtypes = type.split(":") 221 | 222 | let fireData = { 223 | type, 224 | target: this 225 | } 226 | 227 | Object.assign(fireData, data) 228 | 229 | recursiveFire(this._listeners, subtypes, 0, fireData) 230 | 231 | // To ensure that no error events are dropped, print them to the 232 | // console if they have no listeners. 233 | // if (!numFires && endsWith(type, "error")) { 234 | // console.error((data && data.error) || data || "Empty error event") 235 | // } 236 | 237 | if (this._eventParent) { 238 | fireData = {} 239 | Object.getOwnPropertyNames(data).forEach(key => { 240 | fireData[key] = data[key] 241 | }) 242 | this._eventParent.fire(type, fireData) 243 | } 244 | 245 | return this 246 | } 247 | 248 | /** 249 | * Returns whether or not this event hander fires a specific event type 250 | * @param {string} type Event type 251 | * @return {Boolean} 252 | */ 253 | listens(type) { 254 | const subtypes = type.split(":") 255 | let currMap = this._listeners 256 | let data = null 257 | for (let i = 0; i < subtypes.length; i += 1) { 258 | data = currMap.get(subtypes[i]) 259 | if (!data) { 260 | break 261 | } 262 | currMap = data[0] 263 | } 264 | return data || (this._eventParent && this._eventParent.listens(type)) 265 | } 266 | 267 | /** 268 | * Sets a parent event handler 269 | * @param {EventHandler} parent 270 | * @param {object} data 271 | */ 272 | setEventedParent(parent, data) { 273 | this._eventParent = parent 274 | this._eventParentData = data 275 | 276 | return this 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to MapD's Draw library 2 | 👍🎉 Thanks for taking the time to contribute! 🎉👍 3 | 4 | Our team welcomes and appreciates your contributions to this codebase. Every contribution helps, and our team will make sure you're given proper credit for your efforts. 5 | 6 | ### Contributor License Agreement 7 | 8 | In order to clarify the intellectual property license granted with Contributions from any person or entity, MapD must have a Contributor License Agreement (“CLA”) on file that has been signed by each Contributor, indicating agreement to the [Contributor License Agreement](CONTRIBUTOR_LICENSE_AGREEMENT.md). If you have not already done so, please complete and sign, then scan and email a pdf file of this Agreement to contributors@mapd.com. Please read the agreement carefully before signing and keep a copy for your records. 9 | 10 | You can contribute in many ways: 11 | 12 | ### Types of Issues 13 | - [🐞 Bugs](#reporting-bugs) 14 | - [📖 Documentation](#improving-documentation) 15 | - [🆕 Enhancements](#suggesting-enhancements) 16 | 17 | 18 | # 🐞 Reporting Bugs 19 | 20 | This section guides you through submitting a bug report. Following these guidelines helps maintainers and the community understand your report :pencil:, reproduce the behavior :computer:, and find related reports. :mag_right: 21 | 22 | Before creating bug reports, look through [existing issues](https://github.com/mapd/mapd-draw/issues?q=is%3Aopen+is%3Aissue+label%3Abug) as you might find out that you don't need to create one and can just 👍 an existing issue. When you are creating a bug report, [include as many details as possible](#how-do-i-submit-a-good-bug-report). Fill out [the required template](ISSUE_TEMPLATE.md), the information it asks for helps us resolve issues faster. 23 | 24 | ### How Do I Submit A (Good) Bug Report? 25 | 26 | Bugs are tracked as [GitHub issues](https://guides.github.com/features/issues/). Create an issue on that repository and provide the following information by filling in [the template](ISSUE_TEMPLATE.md). 27 | 28 | Explain the problem and include additional details to help maintainers reproduce the problem: 29 | 30 | * **Use a clear and descriptive title** for the issue to identify the problem. 31 | * **Describe the exact steps which reproduce the problem** in as much detail as possible. When listing steps, don't just say *what* you did, but explain *how* you did it. 32 | * **Provide specific examples to demonstrate the steps**. Include links to files or GitHub projects, or copy/pasteable snippets, which you use in those examples. If you're providing snippets in the issue, use [Markdown code blocks](https://help.github.com/articles/markdown-basics/#multiple-lines). 33 | * **Describe the behavior you observed after following the steps** and point out what exactly is the problem with that behavior. 34 | * **Explain which behavior you expected to see instead and why.** 35 | * **Include screenshots and animated GIFs** which show you following the described steps and clearly demonstrate the problem. You can use [this tool](http://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux. However **gifs alone are insufficient** to reproduce bugs! 36 | 37 | 38 | # 📖 Improving Documentation 39 | If you notice anything incorrect or missing from our documentation, correct it and open a PR! 40 | 41 | Correcting typos and clarifying key functions and APIs are two great ways to make this library easier for everyone to use. 42 | 43 | 44 | # 🆕 Suggesting Enhancements 45 | 46 | This section guides you through submitting an enhancement suggestion, ranging from minor improvements for existing functionality to completely new features. Following these guidelines helps maintainers and the community understand your suggestion :pencil: and find related suggestions. :mag_right: 47 | 48 | **Perform a [cursory search](https://github.com/mapd/mapd-draw/issues?q=is%3Aopen+is%3Aissue+label%3Aenhancement)** to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. 49 | 50 | When you are creating an enhancement suggestion, [include as many details as possible](#how-do-i-submit-a-good-enhancement-suggestion). Fill in [the template](ISSUE_TEMPLATE.md), including the steps that you imagine you would take if the feature you're requesting existed. 51 | 52 | ### How Do I Submit A (Good) Enhancement Suggestion? 53 | 54 | * **Use a clear and descriptive title** for the issue to identify the suggestion. 55 | * **Provide a step-by-step description of the suggested enhancement** in as much detail as possible. 56 | * **Provide specific examples to demonstrate the steps**. Include copy/pasteable snippets which you use in those examples, as [Markdown code blocks](https://help.github.com/articles/markdown-basics/#multiple-lines). 57 | * **Describe the current behavior** and **explain which behavior you expected to see instead** and why. 58 | * **Include screenshots and animated GIFs** which help you demonstrate the steps or point out which part the suggestion is related to. You can use [this tool](http://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux. 59 | * **Explain why this enhancement would be useful** to most users and/or devs using this code. 60 | 61 | 62 | # Your First Code Contribution 63 | 64 | Unsure where to begin contributing? You can start by looking for issues tagged `beginner`: 65 | 66 | * [Beginner issues](https://github.com/mapd/mapd-draw/issues?utf8=%E2%9C%93&q=is%3Aopen%20is%3Aissue%20label%3A%22beginner%22%20) - issues which should only require a few lines of code, and a test or two. 67 | 68 | ### Opening Pull Requests 69 | 0. Make sure to commit your built `dist/` files. 70 | 0. Give your PR a commit-worthy name, as we'll squash° the commits into that name. 71 | 0. Fill out the Pull Request checklist ☑️ that's automatically added to your PR, especially what issue(s) you're addressing. 72 | 0. GitHub will automatically check to make sure your code can be merged safely. If it can't, `git rebase master` and push your changes back up. 73 | 0. [TravisCI](travis-ci.com) 👷 will automatically check for lint, passing tests, and no decreases in code coverage. If anything fails, commit a fix and push up to rerun CI. 74 | 0. Once the branch can be merged and CI passes ✅, a core contributor will review the code and make any comments. 75 | 0. There will probably be a bit of back-and-forth during this process as your code changes in response to comments. 76 | 0. Once the PR is approved, we'll squash-merge it in! :trophy: 77 | 78 | ° Squashing makes reverting easier should that become necessary. 79 | 80 | ### Branch Naming 81 | 82 | We use the following branch names to ensure the stability of our codebase. 83 | 84 | ### `develop` 85 | 86 | This is a long-living branch off of master where active development work gets merged in via feature/ or chore/ branches. Do not commit to this branch directly. 87 | 88 | ### `master` 89 | 90 | This is a long-living branch that only contains production-ready code. Stable work should be merged into this branch from a `release/` branch when that code is ready for production. Urgent work may be merged in via `hotfix/` branches. 91 | 92 | ### `feature/` 93 | 94 | Create a temporary `feature/your-cool-feature` branch off of `develop` whenever you want to submit work through the normal release cycle. Your branch lives for as long as it takes for the feature to be complete enough to merge into `develop`, at which point you should rebase `develop` one final time and open a pull request into `develop`. 95 | 96 | ### `chore/` 97 | 98 | Create a temporary `chore/your-maintenance-task` branch off of `develop` when you're factoring/rewriting production code or performing general maintenance to architecture, dependencies, or documentation. Use the same process for merging a `feature/`. 99 | 100 | ### `release/` 101 | 102 | Create a temporary `release/version` branch off of `develop` when there is a viable release candidate. This branch lives for as long as it takes for the release candidate to be ready for production. To prepare a release, bump the version number using `npm version major/minor/patch` and merge into `master`. 103 | 104 | ### `hotfix/` 105 | 106 | Create a temporary `hotfix/your-urgent-matter` branch when an urgent fix needs to be released without merging the code in `develop`. Merge this branch back into `master` when ready, follow the normal release process, then back-merge the hotfix into `develop`. 107 | 108 | ### Style Guide 109 | We use an extensive linter to help prevent typos and to maintain a consistent style in the codebase. The linter runs whenever you run `npm test`. The [linter settings file contains justifications](../.eslintrc.json) for most rules, but we're open to suggestions if you're willing to make the change! 110 | 111 | 112 | # Becoming a Maintainer 113 | We may ask community members who've proven themselves with consistently excellent PR and issue contributions to join our **Maintainers** team. There they'll help curate issues, review pull requests, and keep improving the community they're leading. 114 | 115 | As we grow our internal engineering team, we may consider hiring contributors, particularly those who've earned this level of trust. 116 | 117 | 👍🎉 Again, thanks for contributing! 🎉👍 118 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["eslint:recommended", "prettier"], 3 | plugins: ["prettier"], 4 | rules: { 5 | // And why they're best practice (alphabetized). 6 | "prettier/prettier": "error", 7 | "accessor-pairs": [2, { getWithoutSet: true }], // omission is usually by mistake. 8 | "array-callback-return": 1, // omission is usually by mistake. 9 | "arrow-body-style": [2, "as-needed"], // improves consistency and readability. 10 | "callback-return": [1, ["callback", "cb", "next"]], // usually returns control to cb, so best to return out of function as well. 11 | complexity: 1, // code with high cyclomatic complexity is difficult to reason about. 12 | "consistent-return": 1, // reduces ambiguity about what gets returned. 13 | "consistent-this": [1, "context"], // enforces a standard var for capturing context (which should be done sparingly). 14 | "constructor-super": 2, // catches runtime syntax errors. 15 | curly: 2, // reduces ambiguity around blocks and line breaks. 16 | "default-case": 1, // not having default (and break) lead to unexpected results. 17 | "dot-notation": 2, // easier to read. 18 | eqeqeq: 1, // avoids unexpected type coercion. 19 | "func-names": 0, // having named functions makes following stack traces much easier. 20 | "func-style": [0, "declaration"], // differentiates funcs from consts; hoisting allows more readable code ordering. 21 | "global-require": 0, // avoid unexpected sync file load. 22 | "guard-for-in": 1, // protects against looping over props up prototype chain. 23 | "handle-callback-err": [1, "^.*(e|E)rr"], // often omitted in error. 24 | "id-blacklist": 0, // will add variable names to this list if abused. 25 | "id-length": 0, // variable naming is difficult enough without limits. 26 | "id-match": 0, // covered by camelCase. 27 | "linebreak-style": [2, "unix"], // improves consistency; prevents windows users from introducing \r. 28 | "max-depth": [1, 4], // deeply nested code can be difficult to read. 29 | "max-nested-callbacks": [1, 3], // a sign that the nested code should be refactored out. 30 | "max-params": 0, // better to have many params than obscure them with a config object. 31 | "newline-after-var": 0, // improves consistency; concise code gives reader more context. 32 | "newline-before-return": 0, // vertical space is too precious to be wasted. 33 | "no-alert": 2, // alerts are annoying. 34 | "no-array-constructor": 1, // can do surprising things; better to use []. 35 | "no-bitwise": 0, // these are usually typos; can be difficult to reason about. 36 | "no-caller": 2, // deprecated. 37 | "no-case-declarations": 0, // can lead to unexpected behavior. 38 | "no-catch-shadow": 2, // causes a bug in IE8. 39 | "no-class-assign": 2, // usually a typo. 40 | "no-cond-assign": 0, // usually typos. 41 | "no-console": 1, // for debugging only; shouldn't be committed. 42 | "no-const-assign": 2, // catches runtime syntax errors. 43 | "no-constant-condition": 2, // unnecessary; usually a typo. 44 | "no-continue": 1, // makes reasoning about loops more difficult. 45 | "no-control-regex": 2, // usually a typo. 46 | "no-debugger": 2, // for debugging only; shouldn't be committed. 47 | "no-delete-var": 2, // only properties should be deleted. 48 | "no-div-regex": 0, // regex are difficult enough; also operator-assignment disallows /= operator. 49 | "no-dupe-args": 1, // shadowing increases ambiguity. 50 | "no-dupe-class-members": 2, // can behave unexpectedly, probably a typo. 51 | "no-duplicate-case": 2, // almost certainly a mistake. 52 | "no-duplicate-imports": 2, // should be consolidated for brevity. 53 | "no-else-return": 0, // explicit conditional paths are better than implicit. 54 | "no-empty": 2, // probably a mistake. 55 | "no-empty-character-class": 2, // probably a typo. 56 | "no-empty-function": 1, // probably a mistake. 57 | "no-empty-pattern": 2, // looks like object but is in fact noop. 58 | "no-eval": 2, // eval is often unsafe and performs poorly. 59 | "no-ex-assign": 2, // overwriting params is usually bad; can obscure errors. 60 | "no-extend-native": 2, // can cause unexpected behavior for other devs. 61 | "no-extra-bind": 2, // removes useless code. 62 | "no-extra-boolean-cast": 2, // unnecessary. 63 | "no-extra-label": 2, // don't use labels 64 | "no-fallthrough": 2, // catches mistakes that lead to unexpected behavior. 65 | "no-func-assign": 2, // probably a typo. 66 | "no-implicit-coercion": 2, // avoids fancy coercion tricks that inhibit readability. 67 | "no-implicit-globals": 0, // modules make this rule unnecessary. 68 | "no-implied-eval": 2, // eval is often unsafe and performs poorly. 69 | "no-inline-comments": 0, // helps prevent post-refactor orphaned comments. 70 | "no-invalid-regexp": 2, // the more checks on regex correctness the better. 71 | "no-inner-declarations": 2, // avoids unexpected behavior. 72 | "no-irregular-whitespace": 1, // improves consistency. 73 | "no-iterator": 2, // this feature is obsolete and not widely supported. 74 | "no-label-var": 2, // a bad feature related to loop control flow, like GOTO. 75 | "no-labels": 2, // a bad feature related to loop control flow, like GOTO. 76 | "no-lone-blocks": 2, // unless in es6, these are just useless clutter. 77 | "no-lonely-if": 1, // extra-verbose and unusual. 78 | "no-loop-func": 2, // functions in loops are difficult to reason about. 79 | "no-mixed-requires": 1, // group requires and seperate from other init for clearer code. 80 | "no-multi-str": 2, // use newline chars or template strings instead. 81 | "no-native-reassign": 2, // can cause unexpected behavior for other devs. 82 | "no-negated-in-lhs": 2, // reduces ambiguity and typos. 83 | "no-nested-ternary": 1, // improves reasonability. 84 | "no-new": 2, // using new for side effects is bad because side effects are bad and OO is bad. 85 | "no-new-func": 2, // eval is often unsafe and performs poorly. 86 | "no-new-object": 2, // use more concise {} instead. 87 | "no-new-require": 2, // unusual; just use require. 88 | "no-new-symbol": 2, // should be called as a function without new. 89 | "no-new-wrappers": 2, // does not do what it looks like it does. 90 | "no-obj-calls": 2, // part of the spec. 91 | "no-octal": 2, // can be confusing. 92 | "no-octal-escape": 2, // deprecated. 93 | "no-param-reassign": 0, // useful for guarding a function. 94 | "no-path-concat": 2, // breaks for non-unix system. 95 | "no-plusplus": 0, 96 | "no-process-env": 0, // global deps are bad; better to use config files. 97 | "no-process-exit": 2, // too drastic; almost always better to throw and handle. 98 | "no-proto": 2, // deprecated. 99 | "no-redeclare": [1, { builtinGlobals: true }], // probably a mistake; should use const/let instead anyway. 100 | "no-regex-spaces": 2, // probably a typo. 101 | "no-restricted-globals": 0, // not (yet) necessary. 102 | "no-restricted-imports": 0, // not (yet) necessary. 103 | "no-restricted-modules": 0, // not (yet) necessary. 104 | "no-restricted-syntax": [1, "TryStatement"], // try-catch makes tracing errors difficult (see http://j.mp/thriftthrow). 105 | "no-return-assign": [2, "always"], // can cover up typos. 106 | "no-script-url": 2, // eval is often unsafe and performs poorly. 107 | "no-self-assign": 2, // no effect; probably a typo. 108 | "no-self-compare": 1, // usually a typo; better to use isNaN. 109 | "no-sequences": 2, // usually a typo; obscures side effects. 110 | "no-shadow-restricted-names": 2, // should not mess with restricted. 111 | "no-sparse-arrays": 2, // usually typos. 112 | "no-sync": 2, // blocks single thread; use async. 113 | "no-ternary": 0, // ternaries more concise and more strict than if/else. 114 | "no-this-before-super": 2, // catches a reference error. 115 | "no-throw-literal": 1, // be consistent about only throwing Error objects. 116 | "no-undef": 1, // catches ReferenceErrors. 117 | "no-undef-init": 2, // unnecessary. 118 | "no-unmodified-loop-condition": 2, // possible infinite loop; probably a mistake. 119 | "no-unneeded-ternary": 2, // improves consistency and readability. 120 | "no-unreachable": 2, // helps keep things clean during refactoring. 121 | "no-unsafe-finally": 2, // leads to unexpected behavior. 122 | "no-unused-expressions": 1, // usually a typo; has o effect. 123 | "no-unused-labels": 2, // don't use labels. 124 | "no-unused-vars": 1, // probably a mistake. 125 | "no-use-before-define": [1, { functions: false }], // avoids temporal dead zone; functions below can improve readability. 126 | "no-useless-call": 1, // slower than normal invocation. 127 | "no-useless-computed-key": 2, // unnecessary; can cause confusion. 128 | "no-useless-constructor": 2, // unnecessary. 129 | "no-useless-escape": 1, // makes code easier to read. 130 | "no-var": 1, // const is best, and let is useful for counters, but they eclipse var's uses. #ES6only 131 | "no-void": 2, // unusual and unnecessary. 132 | "no-warning-comments": 1, // warning comments should be addressed before merge (or moved out of code). 133 | "no-with": 2, // can add unexpected variables to scope. 134 | "object-shorthand": 2, // increases consistency. #ES6only 135 | "prefer-arrow-callback": 2, // increases readability and consistency. 136 | "prefer-const": 1, // better to be explicit about what is expected to change. 137 | "prefer-rest-params": 0, // easier to read than slicing args. #ES6only 138 | "prefer-template": 0, 139 | "require-yield": 2, // omission is probably a mistake. 140 | "spaced-comment": 2, // improves consistency. 141 | "use-isnan": 2, // comparing to NaN can be difficult to reason about. 142 | "valid-jsdoc": 0, // not using jsdoc. 143 | "valid-typeof": 2, // there are ways to type-check, but will least prevent typos. 144 | yoda: 2 // improves readability and consistency. 145 | }, 146 | env: { 147 | es6: true, 148 | browser: true, 149 | node: true, 150 | jasmine: true 151 | }, 152 | parserOptions: { 153 | ecmaVersion: 6, 154 | ecmaFeatures: { 155 | arrowFunctions: true, 156 | binaryLiterals: true, 157 | blockBindings: true, 158 | classes: true, 159 | defaultParams: true, 160 | destructuring: true, 161 | forOf: true, 162 | generators: true, 163 | objectLiteralComputedProperties: true, 164 | objectLiteralDuplicateProperties: true, 165 | objectLiteralShorthandMethods: true, 166 | objectLiteralShorthandProperties: true, 167 | octalLiterals: true, 168 | regexUFlag: true, 169 | regexYFlag: true, 170 | restParams: true, 171 | spread: true, 172 | superInFunctions: true, 173 | templateStrings: true, 174 | unicodeCodePointEscapes: true, 175 | globalReturn: true, 176 | experimentalObjectRestSpread: true 177 | }, 178 | sourceType: "module" 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/view/camera2d.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | import * as AABox2d from "../core/aabox2d" 4 | import * as Point2d from "../core/point2d" 5 | import Vec2d from "../core/vec2d" 6 | import Mat2d from "../core/mat2d" 7 | import aggregation from "../util/aggregation" 8 | import { createEventedTransform2dMixin } from "../shapes/transform2d" 9 | import EventHandler from "../util/event-handler" 10 | 11 | /** 12 | * Camera modification event 13 | * @event EventedCamera#changed 14 | * @type {object} 15 | * @property {string} attr Name of the attribute modified 16 | * @property {} prevVal Previous value of the attribute prior to modification 17 | * @property {} currVal Value of the attribute post modification 18 | */ 19 | 20 | /** 21 | * @class Base camera class for 2d screen projections 22 | * The base class is used only for the NDC to screen space trasform 23 | * @extends {EventHandler} 24 | */ 25 | class BaseCamera2d extends EventHandler { 26 | /** 27 | * Constructs a new 2d camera 28 | * @param {AABox2d} viewport Initial camera viewport boundary, in pixels 29 | * @param {Vec2d} projectionDimensions The width and height of the viewport in world coordinates 30 | * @param {Boolean} [flipY=false] Whether world y coordinates are flipped, if false 31 | * the top of the viewport window is smaller in y, and the bottom 32 | * is larger. If true, the bottom has smaller y coords, and the top 33 | * larger 34 | * @return {BaseCamera2d} 35 | */ 36 | constructor(viewport, projectionDimensions, flipY = false) { 37 | // initialize modify events 38 | super(["changed", "changed:xform"]) 39 | 40 | this._viewport = AABox2d.clone(viewport) 41 | this._projectionDimensions = Vec2d.clone(projectionDimensions) 42 | this._yflip = flipY 43 | 44 | // stores the NDC space to screen space matrix 45 | // NDC (normalized device coordinates) is the space 46 | // where the left edge of the window is -1, the right edge is 1 47 | // the top edge is -1, and the bottom edge is 1. 48 | this._screenMatrix = Mat2d.create() 49 | 50 | // this flag is dirty whenever the attributes for the 51 | // ndc-to-screen projection are modified 52 | this._screenDirty = true 53 | } 54 | 55 | /** 56 | * Returns true if the world Y coordinates go from negative to positive 57 | * in a bottom to top fashion in screen space, false otherwise. 58 | * @return {Boolean} 59 | */ 60 | isYFlipped() { 61 | return this._yflip 62 | } 63 | 64 | /** 65 | * Sets the camera's screen-space viewport bounds 66 | * @param {AABox2d} viewport 67 | * @fires EventedCamera#changed 68 | * @return {BaseCamera2d} 69 | */ 70 | set viewport(viewport) { 71 | if (!AABox2d.equals(viewport, this._viewport)) { 72 | const prev = AABox2d.clone(this._viewport) 73 | AABox2d.copy(this._viewport, viewport) 74 | this._screenDirty = true 75 | this._worldToScreenOutdated = true 76 | this.fire("changed", { 77 | attr: "viewport", 78 | prevVal: prev, 79 | currVal: viewport 80 | }) 81 | } 82 | return this 83 | } 84 | 85 | /** 86 | * Gets a copy of the camera's current viewport 87 | * @return {AABox2d} 88 | */ 89 | get viewport() { 90 | return AABox2d.clone(this._viewport) 91 | } 92 | 93 | /** 94 | * Gets a reference to the camera's current viewport 95 | * @return {AABox2d} 96 | * @readOnly 97 | */ 98 | get viewportRef() { 99 | return this._viewport 100 | } 101 | 102 | /** 103 | * Gets the current NDC to screen space transform matrix 104 | * @return {Mat2d} 105 | */ 106 | get screenMatrix() { 107 | if (this._screenDirty) { 108 | const center = Point2d.create() 109 | const extents = Vec2d.create() 110 | AABox2d.getCenter(center, this._viewport) 111 | AABox2d.getExtents(extents, this._viewport) 112 | Mat2d.set( 113 | this._screenMatrix, 114 | extents[0], 115 | 0, 116 | 0, 117 | extents[1], 118 | center[0], 119 | center[1] 120 | ) 121 | this._worldToScreenOutdated = true 122 | this._screenDirty = false 123 | } 124 | return this._screenMatrix 125 | } 126 | } 127 | 128 | /** 129 | * @class Main 2d camera class to manage othographic 2d projections 130 | * @extends {BaseCamera2d} 131 | * @mixin {EventedTransform2d} 132 | */ 133 | export default class Camera2d extends aggregation( 134 | BaseCamera2d, 135 | createEventedTransform2dMixin("changed:xform") 136 | ) { 137 | /** 138 | * Creates a new Camera2d object 139 | * @param {AABox2d} viewport The camera's viewport bounds in pixel space 140 | * @param {Vec2d} projectionDimensions The width/height of the camera's viewport in world coordinates 141 | * @param {Boolean} flipY True if the direction from negative to positive Y coordinates 142 | * go from the bottom to top of the window. False means Y coords 143 | * from negative to positive values go from the top to the bottom 144 | * @return {Camera2d} 145 | */ 146 | constructor(viewport, projectionDimensions, flipY) { 147 | super(viewport, projectionDimensions, flipY) 148 | 149 | this._viewMatrix = Mat2d.create() 150 | this._viewDirty = true 151 | this._projMatrix = Mat2d.create() 152 | this._projDirty = true 153 | } 154 | 155 | /** 156 | * Sets the projection dimensions of the camera's view. This is the width/height 157 | * in world space coordiantes of the camera's view. 158 | * @param {Vec2d} projectionDimensions 159 | * @return {Camera2d} this 160 | */ 161 | set projectionDimensions(projectionDimensions) { 162 | if (!Vec2d.equals(projectionDimensions, this._projectionDimensions)) { 163 | const prev = Vec2d.clone(this._projectionDimensions) 164 | AABox2d.copy(this._projectionDimensions, projectionDimensions) 165 | this._viewDirty = true 166 | this._projDirty = true 167 | // this._yflip = this._projectionDimensions[3] < this._projectionDimensions[1] 168 | this.fire("changed", { 169 | attr: "projectionDimensions", 170 | prevVal: prev, 171 | currVal: projectionDimensions 172 | }) 173 | } 174 | return this 175 | } 176 | 177 | /** 178 | * Gets a copy of the camera's current projection dimensions 179 | * @return {Vec2d} 180 | */ 181 | get projectionDimensions() { 182 | return Vec2d.clone(this._projectionDimensions) 183 | } 184 | 185 | /** 186 | * Gets a reference to the camera's current projection dimensions 187 | * @return {Vec2d} 188 | * @readOnly 189 | */ 190 | get projectionDimensionsRef() { 191 | return this._projectionDimensions 192 | } 193 | 194 | /** 195 | * Called when the local transform (does not include parent transforms) of the Transform2d 196 | * mixin is modified. Used to be notified internally that the view-to-screen space 197 | * transformation needs updating 198 | * @private 199 | */ 200 | _localXformUpdated() { 201 | this._boundsOutOfDate = true 202 | } 203 | 204 | /** 205 | * Called when the global matrix (includes parent transforms) of the 206 | * Transform2d mixin is modified. Used to be notified internally that 207 | * the view-to-screen space transformation needs updating 208 | * @private 209 | */ 210 | _globalXformUpdated() { 211 | this._boundsOutOfDate = true 212 | } 213 | 214 | /** 215 | * Gets the transformation matrix from world space to view/camera space 216 | * @return {Mat2d} 217 | */ 218 | get viewMatrix() { 219 | if ( 220 | this._viewDirty || 221 | this._boundsOutOfDate || 222 | this._xformDirty || 223 | this._lxformDirty 224 | ) { 225 | // the matrix has been marked dirty, so recalculate 226 | const pos = Point2d.create() 227 | const scale = Vec2d.create() 228 | const rot = Vec2d.create() 229 | const xform = this.globalXform 230 | Mat2d.svd(pos, scale, rot, xform) 231 | Mat2d.fromTranslation(this._viewMatrix, Vec2d.negate(pos, pos)) 232 | Mat2d.rotate(this._viewMatrix, this._viewMatrix, -rot[0]) 233 | Mat2d.scale(this._viewMatrix, this._viewMatrix, scale) 234 | Mat2d.rotate(this._viewMatrix, this._viewMatrix, -rot[1]) 235 | this._worldToScreenOutdated = true 236 | this._viewDirty = false 237 | } 238 | return this._viewMatrix 239 | } 240 | 241 | /** 242 | * Gets the orthographic projection transformation matrix from 243 | * view to NDC (normalized device coordinates) space 244 | * @return {Mat2d} 245 | */ 246 | get projMatrix() { 247 | if (this._projDirty) { 248 | const flip = this._yflip ? -1 : 1 249 | Mat2d.set( 250 | this._projMatrix, 251 | 2.0 / this._projectionDimensions[0], 252 | 0, 253 | 0, 254 | (flip * 2.0) / this._projectionDimensions[1], 255 | 0, 256 | 0 257 | ) 258 | this._worldToScreenOutdated = true 259 | this._projDirty = false 260 | } 261 | return this._projMatrix 262 | } 263 | 264 | /** 265 | * Returns true if any of the dirty flags are active 266 | * @return {Boolean} 267 | * @private 268 | */ 269 | _matricesDirty() { 270 | return ( 271 | this._boundsOutOfDate || 272 | this._lxformDirty || 273 | this._xformDirty || 274 | this._viewDirty || 275 | this._projDirty || 276 | this._screenDirty 277 | ) 278 | } 279 | 280 | /** 281 | * Gets the transformation matrix from world space to screen space. 282 | * @return {Mat2d} 283 | */ 284 | get worldToScreenMatrix() { 285 | if ( 286 | !this._worldToScreenMatrix || 287 | this._worldToScreenOutdated || 288 | this._matricesDirty() 289 | ) { 290 | if (!this._worldToScreenMatrix) { 291 | this._worldToScreenMatrix = Mat2d.create() 292 | } 293 | Mat2d.copy(this._worldToScreenMatrix, this.viewMatrix) 294 | Mat2d.multiply( 295 | this._worldToScreenMatrix, 296 | this.projMatrix, 297 | this._worldToScreenMatrix 298 | ) 299 | Mat2d.multiply( 300 | this._worldToScreenMatrix, 301 | this.screenMatrix, 302 | this._worldToScreenMatrix 303 | ) 304 | this._worldToScreenOutdated = false 305 | this._screenToWorldOutdated = true 306 | } 307 | return this._worldToScreenMatrix 308 | } 309 | 310 | /** 311 | * Gets teh transform matrix from screen space to world space. 312 | * @return {[type]} [description] 313 | */ 314 | get screenToWorldMatrix() { 315 | if ( 316 | !this._screenToWorld || 317 | this._screenToWorldOutdated || 318 | this._matricesDirty() 319 | ) { 320 | if (!this._screenToWorld) { 321 | this._screenToWorld = Mat2d.create() 322 | } 323 | Mat2d.copy(this._screenToWorld, this.worldToScreenMatrix) 324 | Mat2d.invert(this._screenToWorld, this._screenToWorld) 325 | this._screenToWorldOutdated = false 326 | } 327 | return this._screenToWorld 328 | } 329 | 330 | /** 331 | * Gets the axis-aligned bounding box of the current view in world-space coordinates 332 | * @return {AABox2d} 333 | */ 334 | get worldViewBounds() { 335 | // get a clone of the current screen space viewport 336 | const viewport_clone = this.viewport 337 | AABox2d.transformMat2d( 338 | viewport_clone, 339 | viewport_clone, 340 | this.screenToWorldMatrix 341 | ) 342 | return viewport_clone 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /src/interactions/xform-shape.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | import * as AABox2d from "../core/aabox2d" 4 | import * as Point2d from "../core/point2d" 5 | import { buildXformMatrix } from "../shapes/transform2d" 6 | import Mat2d from "../core/mat2d" 7 | import Rect from "../shapes/rect" 8 | import Vec2d from "../core/vec2d" 9 | 10 | const Constants = { 11 | SIDES: 4 12 | } 13 | 14 | function drawOBBoxModifierRect( 15 | ctx, 16 | pt, 17 | objToScreenMat, 18 | modifierSize, 19 | modifierHalfSize, 20 | modifierRotation, 21 | scale 22 | ) { 23 | Point2d.transformMat2d(pt, pt, objToScreenMat) 24 | ctx.setTransform(1, 0, 0, 1, 0, 0) 25 | ctx.translate(pt[0], pt[1]) 26 | ctx.rotate(modifierRotation[1]) 27 | ctx.scale(scale[0], scale[1]) 28 | ctx.rotate(modifierRotation[0]) 29 | ctx.translate(-pt[0], -pt[1]) 30 | ctx.rect( 31 | pt[0] - modifierHalfSize, 32 | pt[1] - modifierHalfSize, 33 | modifierSize, 34 | modifierSize 35 | ) 36 | 37 | // ctx.moveTo(pt[0], pt[1]) 38 | // ctx.lineTo(pt[0], pt[1] + 20) 39 | // ctx.moveTo(pt[0], pt[1]) 40 | // ctx.lineTo(pt[0] + 20, pt[1]) 41 | 42 | // ctx.font = "24px serif" 43 | // ctx.fillText(text, pt[0], pt[1]) 44 | } 45 | 46 | function isPointInOBBoxModifierRect( 47 | screenPt, 48 | modifierPt, 49 | objToScreenMat, 50 | modifierMat, 51 | halfBoxSz, 52 | boxSz, 53 | padBoxSz, 54 | padBoxRadius, 55 | xScale, 56 | yScale 57 | ) { 58 | let hit = false 59 | let rotate = false 60 | Point2d.transformMat2d(modifierPt, modifierPt, objToScreenMat) 61 | if (Point2d.distance(modifierPt, screenPt) <= padBoxRadius) { 62 | Vec2d.negate(modifierPt, modifierPt) 63 | modifierMat[4] = 64 | modifierMat[0] * modifierPt[0] + modifierMat[2] * modifierPt[1] 65 | modifierMat[5] = 66 | modifierMat[1] * modifierPt[0] + modifierMat[3] * modifierPt[1] 67 | 68 | let x = 0 69 | let y = 0 70 | Point2d.transformMat2d(modifierPt, screenPt, modifierMat) 71 | if ( 72 | (Math.abs(modifierPt[0]) <= halfBoxSz && 73 | Math.abs(modifierPt[1]) <= halfBoxSz) || 74 | (Boolean(xScale) && 75 | Boolean(yScale) && 76 | (rotate = 77 | (x = xScale * modifierPt[0]) > -halfBoxSz && 78 | x <= padBoxSz && 79 | (y = yScale * modifierPt[1]) > -halfBoxSz && 80 | y <= padBoxSz)) 81 | ) { 82 | hit = true 83 | } 84 | } 85 | return { 86 | hit, 87 | rotate 88 | } 89 | } 90 | 91 | export default class XformShape extends Rect { 92 | constructor( 93 | opts, 94 | selectOpts = { 95 | scalable: true, 96 | rotatable: true 97 | } 98 | ) { 99 | super(opts) 100 | this._interactiveBoxSize = 8 101 | this._interactiveBoxPadding = 10 102 | if (opts && typeof opts.vertexSize !== "undefined") { 103 | this._interactiveBoxSize = opts.vertexSize 104 | } 105 | 106 | this._scalable = true 107 | this._rotatable = true 108 | if (selectOpts) { 109 | if (typeof selectOpts.scalable !== "undefined") { 110 | this._scalable = Boolean(selectOpts.scalable) 111 | } 112 | 113 | if (typeof selectOpts.rotatable !== "undefined") { 114 | this._rotatable = Boolean(selectOpts.rotatable) 115 | } 116 | } 117 | } 118 | 119 | containsPoint(screenPt) { 120 | // Should we update here, or is it safe to 121 | // say that this is stateful, meaning a render 122 | // should have been performed beforehand which 123 | // would've updated its state 124 | let rtnObj = { 125 | hit: false, 126 | rotate: false, 127 | controlIndex: -1 128 | } 129 | 130 | const aabox = this.aabox 131 | if ( 132 | (this._rotatable || this._scalable) && 133 | this.visible && 134 | AABox2d.containsPt(aabox, screenPt) 135 | ) { 136 | const scale = Vec2d.create() 137 | const rot = Vec2d.create() 138 | Mat2d.svd(null, scale, rot, this._fullXform) 139 | const mat = Mat2d.create() 140 | 141 | scale[0] = scale[0] < 0 ? -1 : 1 142 | scale[1] = scale[1] < 0 ? -1 : 1 143 | 144 | if (scale[0] * scale[1] > 0) { 145 | Vec2d.negate(rot, rot) 146 | } 147 | 148 | Mat2d.rotate(mat, mat, rot[1]) 149 | Mat2d.scale(mat, mat, scale) 150 | Mat2d.rotate(mat, mat, rot[0]) 151 | 152 | const boxPadding = this._rotatable ? this._interactiveBoxPadding : 0 153 | const halfBoxSz = this._interactiveBoxSize / 2 154 | const halfWidth = this.width / 2 155 | const halfHeight = this.height / 2 156 | const pt = Point2d.create() 157 | const padSz = halfBoxSz + boxPadding 158 | const padRadius = Math.sqrt(2 * padSz * padSz) 159 | let xScale = 0 160 | let yScale = 0 161 | 162 | for (let i = 0; i < Constants.SIDES; i += 1) { 163 | xScale = i < 2 ? -1 : 1 164 | yScale = i % 2 === 0 ? -1 : 1 165 | Point2d.set(pt, xScale * halfWidth, yScale * halfHeight) 166 | rtnObj = isPointInOBBoxModifierRect( 167 | screenPt, 168 | pt, 169 | this._fullXform, 170 | mat, 171 | halfBoxSz, 172 | this._interactiveBoxSize, 173 | padSz, 174 | padRadius, 175 | xScale, 176 | yScale, 177 | scale 178 | ) 179 | if (rtnObj.hit) { 180 | rtnObj.controlIndex = i 181 | break 182 | } 183 | } 184 | 185 | if (!rtnObj.hit && this._scalable) { 186 | for (let i = 0; i < Constants.SIDES; i += 1) { 187 | xScale = i % 2 === 0 ? (i < 2 ? -1 : 1) : 0 188 | yScale = i % 2 === 0 ? 0 : i < 2 ? -1 : 1 189 | Point2d.set(pt, xScale * halfWidth, yScale * halfHeight) 190 | rtnObj = isPointInOBBoxModifierRect( 191 | screenPt, 192 | pt, 193 | this._fullXform, 194 | mat, 195 | halfBoxSz, 196 | this._interactiveBoxSize, 197 | padSz, 198 | padRadius, 199 | xScale, 200 | yScale, 201 | scale 202 | ) 203 | if (rtnObj.hit) { 204 | rtnObj.controlIndex = i + Constants.SIDES 205 | break 206 | } 207 | } 208 | } 209 | 210 | if (rtnObj.rotate && !this._rotatable) { 211 | rtnObj.rotate = false 212 | } else if (!rtnObj.rotate && !this._scalable) { 213 | rtnObj.rotate = true 214 | } 215 | } 216 | 217 | return rtnObj 218 | } 219 | 220 | _updatelocalxform(force) { 221 | if (this._lxformDirty || force) { 222 | const pos = Point2d.clone(this._pos) 223 | Point2d.addVec2(pos, pos, this._parent.pivotRef) 224 | buildXformMatrix( 225 | this._localXform, 226 | this._rotDeg, 227 | this._scale, 228 | pos, 229 | this._pivot 230 | ) 231 | if (this._localXformUpdated) { 232 | this._localXformUpdated() 233 | } 234 | this._lxformDirty = false 235 | } 236 | } 237 | 238 | _updateglobalxform() { 239 | if (this._lxformDirty || this._xformDirty) { 240 | this._updatelocalxform(true) 241 | if (this._parent) { 242 | Mat2d.multiply( 243 | this._globalXform, 244 | this._parent.globalXform, 245 | this._localXform 246 | ) 247 | } else { 248 | Mat2d.copy(this._globalXform, this._localXform) 249 | } 250 | if (this._globalXformUpdated) { 251 | this._globalXformUpdated() 252 | } 253 | this._xformDirty = false 254 | } 255 | } 256 | 257 | _updateAABox(force = false) { 258 | if (force || this._geomDirty || this._boundsOutOfDate) { 259 | const boxPadding = this._rotatable ? this._interactiveBoxPadding : 0 260 | const padding = boxPadding + this._interactiveBoxSize / 2 261 | AABox2d.initCenterExtents(this._aabox, Point2d.create(0, 0), [ 262 | this.width / 2, 263 | this.height / 2 264 | ]) 265 | AABox2d.transformMat2d(this._aabox, this._aabox, this._fullXform) 266 | AABox2d.expand(this._aabox, this._aabox, [padding, padding]) 267 | this._aaboxUpdated = true 268 | this._geomDirty = this._boundsOutOfDate = false 269 | } 270 | } 271 | 272 | get width() { 273 | return this.parent && this.parent.width !== "undefined" 274 | ? this.parent.width 275 | : 0 276 | } 277 | 278 | get height() { 279 | return this.parent && this.parent.height !== "undefined" 280 | ? this.parent.height 281 | : 0 282 | } 283 | 284 | renderBounds(ctx, worldToScreenMatrix, boundsStrokeStyle) { 285 | // we're storing our AABox in screen space here, so worldToScreenMatrix is 286 | // unused 287 | const aabox = this.aabox 288 | ctx.save() 289 | ctx.setTransform(1, 0, 0, 1, 0, 0) 290 | boundsStrokeStyle.setStrokeCtx(ctx) 291 | const center = Point2d.create() 292 | const extents = Vec2d.create() 293 | AABox2d.getCenter(center, aabox) 294 | AABox2d.getExtents(extents, aabox) 295 | ctx.beginPath() 296 | ctx.rect( 297 | center[0] - extents[0], 298 | center[1] - extents[1], 299 | extents[0] * 2, 300 | extents[1] * 2 301 | ) 302 | ctx.stroke() 303 | ctx.restore() 304 | } 305 | 306 | render(ctx, worldToScreenMatrix, styleState) { 307 | if ( 308 | !this.parent || 309 | typeof this.parent.width === "undefined" || 310 | this.parent.height === "undefined" 311 | ) { 312 | return 313 | } 314 | 315 | this._aaboxUpdated = false 316 | // do not fill the primary rectangle 317 | super.render(ctx, worldToScreenMatrix, styleState, false) 318 | if (!this._aaboxUpdated) { 319 | this._updateAABox(true) 320 | } 321 | 322 | const scale = Vec2d.create() 323 | const rot = Vec2d.create() 324 | Mat2d.svd(null, scale, rot, this._fullXform) 325 | scale[0] = scale[0] < 0 ? -1 : 1 326 | scale[1] = scale[1] < 0 ? -1 : 1 327 | 328 | const halfBoxSz = this._interactiveBoxSize / 2 329 | const halfWidth = this.width / 2 330 | const halfHeight = this.height / 2 331 | const pt = [halfWidth, halfHeight] 332 | 333 | ctx.save() 334 | 335 | ctx.beginPath() 336 | drawOBBoxModifierRect( 337 | ctx, 338 | pt, 339 | this._fullXform, 340 | this._interactiveBoxSize, 341 | halfBoxSz, 342 | rot, 343 | scale, 344 | "3" 345 | ) 346 | 347 | Point2d.set(pt, halfWidth, -halfHeight) 348 | drawOBBoxModifierRect( 349 | ctx, 350 | pt, 351 | this._fullXform, 352 | this._interactiveBoxSize, 353 | halfBoxSz, 354 | rot, 355 | scale, 356 | "2" 357 | ) 358 | 359 | Point2d.set(pt, -halfWidth, -halfHeight) 360 | drawOBBoxModifierRect( 361 | ctx, 362 | pt, 363 | this._fullXform, 364 | this._interactiveBoxSize, 365 | halfBoxSz, 366 | rot, 367 | scale, 368 | "0" 369 | ) 370 | 371 | Point2d.set(pt, -halfWidth, halfHeight) 372 | drawOBBoxModifierRect( 373 | ctx, 374 | pt, 375 | this._fullXform, 376 | this._interactiveBoxSize, 377 | halfBoxSz, 378 | rot, 379 | scale, 380 | "1" 381 | ) 382 | 383 | if (this._scalable) { 384 | Point2d.set(pt, 0, halfHeight) 385 | drawOBBoxModifierRect( 386 | ctx, 387 | pt, 388 | this._fullXform, 389 | this._interactiveBoxSize, 390 | halfBoxSz, 391 | rot, 392 | scale 393 | ) 394 | 395 | Point2d.set(pt, 0, -halfHeight) 396 | drawOBBoxModifierRect( 397 | ctx, 398 | pt, 399 | this._fullXform, 400 | this._interactiveBoxSize, 401 | halfBoxSz, 402 | rot, 403 | scale 404 | ) 405 | 406 | Point2d.set(pt, halfWidth, 0) 407 | drawOBBoxModifierRect( 408 | ctx, 409 | pt, 410 | this._fullXform, 411 | this._interactiveBoxSize, 412 | halfBoxSz, 413 | rot, 414 | scale 415 | ) 416 | 417 | Point2d.set(pt, -halfWidth, 0) 418 | drawOBBoxModifierRect( 419 | ctx, 420 | pt, 421 | this._fullXform, 422 | this._interactiveBoxSize, 423 | halfBoxSz, 424 | rot, 425 | scale 426 | ) 427 | } 428 | 429 | if (this.isFillVisible()) { 430 | styleState.setFillStyle(ctx, this) 431 | ctx.fill() 432 | } 433 | 434 | if (this.isStrokeVisible()) { 435 | styleState.setStrokeStyle(ctx, this) 436 | ctx.setTransform(1, 0, 0, 1, 0, 0) 437 | ctx.stroke() 438 | } 439 | 440 | ctx.restore() 441 | } 442 | } 443 | -------------------------------------------------------------------------------- /src/shapes/base-shape.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | import * as AABox2d from "../core/aabox2d" 4 | import * as Point2d from "../core/point2d" 5 | import Vec2d from "../core/vec2d" 6 | import Mat2d from "../core/mat2d" 7 | import FillStyle, { createEventedFillStyleMixin } from "../style/fill-style" 8 | import StrokeStyle, { 9 | createEventedStrokeStyleMixin 10 | } from "../style/stroke-style" 11 | import Transform2d, { createEventedTransform2dMixin } from "./transform2d" 12 | import aggregation from "../util/aggregation" 13 | import BasicStyle from "../style/basic-style" 14 | import EventHandler from "../util/event-handler" 15 | 16 | /** 17 | * @typedef {object} CoreShapeOptions 18 | * @property {number} [zIndex=0] Z index draw order of the shape. Lower numbers get drawn before larger numbers 19 | */ 20 | 21 | /** 22 | * Shape geom modification event 23 | * @event Shape#geomChanged 24 | * @type {object} 25 | * @property {string} attr Name of the attribute modified 26 | * @property {} prevVal Previous value of the attribute prior to modification 27 | * @property {} currVal Value of the attribute post modification 28 | */ 29 | 30 | /** 31 | * Shape modification event 32 | * @event EventedShape#changed 33 | * @type {object} 34 | * @property {string} attr Name of the attribute modified 35 | * @property {} prevVal Previous value of the attribute prior to modification 36 | * @property {} currVal Value of the attribute post modification 37 | */ 38 | 39 | /** 40 | * @class Defines the core functionality for all shapes 41 | * @extends {EventHandler} 42 | */ 43 | class CoreBaseShape extends EventHandler { 44 | /** 45 | * Creates new core functionality for a shape 46 | * @param {CoreShapeOptions} [opts] 47 | * @return {CoreBaseShape} 48 | */ 49 | constructor(opts) { 50 | super([ 51 | "changed:xform", 52 | "changed:style", 53 | "changed:order", 54 | "changed:visibility", 55 | "changed:geom" 56 | ]) 57 | this._aabox = AABox2d.create() 58 | this._zIndex = opts && opts.zIndex ? opts.zIndex : 0 59 | this._visible = true 60 | this._geomDirty = false 61 | 62 | this._fullXform = Mat2d.create() 63 | } 64 | 65 | /** 66 | * Sets the z index (back to front draw order) of the shape 67 | * @param {number} zIndex 68 | * @fires EventedShape#changed 69 | * @return {CoreBaseShape} this 70 | */ 71 | set zIndex(zIndex) { 72 | if (!Number.isInteger(zIndex)) { 73 | throw new Error("zIndex must be an integer") 74 | } 75 | if (zIndex !== this._zIndex) { 76 | const prev = this._zIndex 77 | this._zIndex = zIndex 78 | this.fire("changed:order", { 79 | attr: "zIndex", 80 | prevVal: prev, 81 | currVal: this._zIndex 82 | }) 83 | } 84 | return this 85 | } 86 | 87 | /** 88 | * Gets the current z index (i.e. draw/layer order) of the shape 89 | * @return {number} 90 | */ 91 | get zIndex() { 92 | return this._zIndex 93 | } 94 | 95 | /** 96 | * Gets a reference to the current axis-aligned bounding box of the 97 | * shape 98 | * @return {AABox2d} 99 | * @readOnly 100 | */ 101 | get aabox() { 102 | this._updateAABox() 103 | return this._aabox 104 | } 105 | 106 | /** 107 | * Sets the visibility of the shape 108 | * @param {Boolean} visible If true, the shape is considered visible 109 | * @fires EventedShape#changed 110 | * @return {CoreBaseShape} this 111 | * @throws {Error} If argument is not a boolean type 112 | */ 113 | set visible(visible) { 114 | if (typeof visible !== "boolean") { 115 | throw new Error("visible must be a boolean") 116 | } 117 | 118 | if (visible !== this._visible) { 119 | this._visible = visible 120 | this.fire("changed:visibility", { 121 | attr: "visible", 122 | prevVal: !this._visible, 123 | currVal: this._visible 124 | }) 125 | } 126 | 127 | return this 128 | } 129 | 130 | /** 131 | * Gets the current visibility of the shape 132 | * @return {boolean} 133 | */ 134 | get visible() { 135 | return this._visible 136 | } 137 | } 138 | 139 | /** 140 | * @class Defines the basic functionality of all shapes. This includes 141 | * mixing in from EventedTransform2d so that affine transformations 142 | * can be applied to the shape. Also includes mixing in fill and stroke 143 | * properties so that the shape's renderable properties can be modified. 144 | * @extends {CoreBaseShape} 145 | * @mixin {EventedTransform2d} 146 | * @mixin {EventedFillStyle} 147 | * @mixin {EventedStrokeStyle} 148 | */ 149 | export default class BaseShape extends aggregation( 150 | CoreBaseShape, 151 | createEventedTransform2dMixin("changed:xform"), 152 | createEventedFillStyleMixin("changed:style"), 153 | createEventedStrokeStyleMixin("changed:style") 154 | ) { 155 | /** 156 | * Creates new basic functionality (including transform, fill style, and stroke style properties) 157 | * for a shape 158 | * @param {object} opts 159 | * @return {BaseShape} 160 | */ 161 | constructor(opts) { 162 | super(opts) 163 | this._stateStack = [] 164 | 165 | // if true, the render loop will call the _drawDebug method, if it exists 166 | // This can be used to active debug draw rendering for extra visual markers 167 | // useful in debugging the drawn shape. 168 | const { debug } = opts 169 | this._doDebugDraw = 170 | typeof debug === "boolean" && 171 | debug && 172 | typeof this._drawDebug === "function" 173 | } 174 | 175 | /** 176 | * Saves the current state of the shape so that it can be restored later. 177 | * @return {BaseShape} this 178 | */ 179 | save() { 180 | // Currently only the state of the fill/stroke style properties 181 | // and the z index are saved. May want to expand this to include 182 | // all modifiable properties (i.e. transform props and visibility prop) 183 | const state = new BasicStyle() 184 | BasicStyle.copyBasicStyle(this, state) 185 | state.zIndex = this.zIndex 186 | this._stateStack.push(state) 187 | return this 188 | } 189 | 190 | /** 191 | * Pops a saved state from the top of the saved state stack 192 | * @return {BaseShape} this 193 | */ 194 | restore() { 195 | // Currently only restores the state of the fill/stroke style properties 196 | // and the z index. May want to expand this to include 197 | // all modifiable properties (i.e. transform props and visibility prop) 198 | const state = this._stateStack.pop() 199 | if (state) { 200 | BasicStyle.copyBasicStyle(state, this) 201 | this.zIndex = state.zIndex 202 | } 203 | return this 204 | } 205 | 206 | /** 207 | * Gets the visibility of the shape 208 | * @return {boolean} 209 | * @override 210 | */ 211 | get visible() { 212 | return this._visible && (this.isFillVisible() || this.isStrokeVisible()) 213 | } 214 | 215 | /** 216 | * Gets the width/height of the shape after the parent transforms are applied 217 | * @return {Vec2d} Width/Height of the shape after all parent transforms applied 218 | */ 219 | getGlobalDimensions() { 220 | const scale = Vec2d.create() 221 | Mat2d.svd(null, scale, null, this.globalXform) 222 | scale[0] *= this.width 223 | scale[1] *= this.height 224 | return scale 225 | } 226 | 227 | /** 228 | * Returns true the shape contains a screen/world space point 229 | * @param {Point2d} screenPt The point to check in screen/pixel space 230 | * @param {Poitn2d} worldPt The point to check in world space 231 | * @param {Mat2d} worldToScreenMatrix The transform matrix from world to screen space 232 | * @param {CanvasRenderingContext2D} ctx The 2d rendering context 233 | * @return {boolean} True if the shape contains the point, false otherwise 234 | */ 235 | containsPoint(screenPt, worldPt, worldToScreenMatrix, ctx) { 236 | // Should we update here, or is it safe to 237 | // say that this is stateful, meaning a render 238 | // should have been performed beforehand which 239 | // would've updated its state 240 | let rtn = false 241 | const aabox = this.aabox 242 | 243 | // Check if the point is contained by the shape's bounds first 244 | if (this.visible && AABox2d.containsPt(aabox, worldPt)) { 245 | // re-draw the shape (invisible) so that we can use canvas's 246 | // isPointInPath/isPointInStroke api calls. Doing that 247 | // as this should be compatible across all browsers 248 | ctx.save() 249 | ctx.setTransform(1, 0, 0, 1, 0, 0) 250 | ctx.beginPath() 251 | this._draw(ctx) 252 | ctx.strokeStyle = "rgba(0,0,0,0)" 253 | ctx.lineWidth = this.strokeWidth + 5 // eslint-disable-line no-magic-numbers 254 | ctx.dashPattern = [] 255 | ctx.setTransform(1, 0, 0, 1, 0, 0) 256 | ctx.stroke() 257 | if ( 258 | (this.isFillVisible() && ctx.isPointInPath(screenPt[0], screenPt[1])) || 259 | (this.isStrokeVisible() && 260 | ctx.isPointInStroke(screenPt[0], screenPt[1])) 261 | ) { 262 | rtn = true 263 | } 264 | ctx.restore() 265 | } 266 | return rtn 267 | } 268 | 269 | /** 270 | * Debug function to draw the bounds of the shape 271 | * @param {CanvasRenderingContext2D} ctx 2d rendering context 272 | * @param {Mat2d} worldToScreenMatrix Transform from world to screen space 273 | * @param {StrokeStyle} boundsStrokeStyle The stroke style to use to render the bounds 274 | */ 275 | renderBounds(ctx, worldToScreenMatrix, boundsStrokeStyle) { 276 | ctx.save() 277 | ctx.setTransform(1, 0, 0, 1, 0, 0) 278 | boundsStrokeStyle.setStrokeCtx(ctx) 279 | const corner_point = Point2d.create() 280 | const center = Point2d.create() 281 | const extents = Point2d.create() 282 | const aabox = this.aabox 283 | AABox2d.getCenter(center, aabox) 284 | AABox2d.getExtents(extents, aabox) 285 | 286 | ctx.beginPath() 287 | 288 | Point2d.set(corner_point, center[0] - extents[0], center[1] - extents[1]) 289 | Point2d.transformMat2d(corner_point, corner_point, worldToScreenMatrix) 290 | ctx.moveTo(corner_point[0], corner_point[1]) 291 | 292 | Point2d.set(corner_point, center[0] + extents[0], center[1] - extents[1]) 293 | Point2d.transformMat2d(corner_point, corner_point, worldToScreenMatrix) 294 | ctx.lineTo(corner_point[0], corner_point[1]) 295 | 296 | Point2d.set(corner_point, center[0] + extents[0], center[1] + extents[1]) 297 | Point2d.transformMat2d(corner_point, corner_point, worldToScreenMatrix) 298 | ctx.lineTo(corner_point[0], corner_point[1]) 299 | 300 | Point2d.set(corner_point, center[0] - extents[0], center[1] + extents[1]) 301 | Point2d.transformMat2d(corner_point, corner_point, worldToScreenMatrix) 302 | ctx.lineTo(corner_point[0], corner_point[1]) 303 | 304 | ctx.closePath() 305 | 306 | ctx.setTransform(1, 0, 0, 1, 0, 0) 307 | ctx.stroke() 308 | ctx.restore() 309 | } 310 | 311 | /** 312 | * Called when the local transform (does not include parent transforms) of the Transform2d 313 | * mixin is modified. Used to be notified internally that the shape's bounds needs updating 314 | * @private 315 | */ 316 | _localXformUpdated() { 317 | this._boundsOutOfDate = true 318 | } 319 | 320 | /** 321 | * Called when the global transform (includes parent transforms) of the Transform2d 322 | * mixin is modified. Used to be notified internally that the shape's bounds needs updating 323 | * @private 324 | */ 325 | _globalXformUpdated() { 326 | this._boundsOutOfDate = true 327 | } 328 | 329 | /** 330 | * Renders the shape using a 2d rendering context 331 | * @param {CanvasRenderingContext2d} ctx 2d rendering context 332 | * @param {Mat2d} worldToScreenMatrix Transform from world to screen space, 333 | * usually provided by a camera 334 | * @param {DrawStyleState} styleState Manages the current state of the fill/stroke style attrs 335 | * of the 2d rendering context. This is self-managed to minimize 336 | * context state switches 337 | * @param {boolean} [doFill=null] If provided, used to manually override whether to fill the 338 | * shape. 339 | * @param {boolean} [doStroke=null] If provided, used to manually override whether to stroke the 340 | * shape. 341 | */ 342 | render(ctx, worldToScreenMatrix, styleState, doFill = null, doStroke = null) { 343 | Mat2d.multiply(this._fullXform, worldToScreenMatrix, this.globalXform) 344 | 345 | ctx.beginPath() 346 | 347 | const rtn = this._draw(ctx) 348 | if (rtn || typeof rtn === "undefined") { 349 | if (this.isFillVisible() && (doFill === null || Boolean(doFill))) { 350 | styleState.setFillStyle(ctx, this) 351 | ctx.fill() 352 | } 353 | 354 | if (this.isStrokeVisible() && (doStroke === null || Boolean(doStroke))) { 355 | styleState.setStrokeStyle(ctx, this) 356 | ctx.setTransform(1, 0, 0, 1, 0, 0) 357 | ctx.stroke() 358 | } 359 | 360 | if (this._doDebugDraw) { 361 | this._drawDebug(ctx) 362 | } 363 | } 364 | } 365 | 366 | /** 367 | * Copies the fill/stroke style from one BasicStyle object to this shape 368 | * @param {BasicStyle} newStyle The style to copy from. 369 | * @return {BaseShape} this 370 | */ 371 | setStyle(newStyle) { 372 | BasicStyle.copyBasicStyle(newStyle, this) 373 | return this 374 | } 375 | 376 | /** 377 | * Returns a JSON object containing the properties of this shape 378 | * @return {object} 379 | */ 380 | toJSON() { 381 | let state = this // eslint-disable-line consistent-this 382 | if (this._stateStack && this._stateStack.length) { 383 | state = this._stateStack[0] 384 | } 385 | return Object.assign( 386 | { 387 | // type: this.constructor.name, 388 | // NOTE: I wanted to use the above call, which would keep the type 389 | // consistent with the name of the class, but this isn't always 390 | // the case, as was found out a few times when trying to add 391 | // this to immerse 392 | visible: this.visible, 393 | zIndex: state.zIndex 394 | }, 395 | BasicStyle.toJSON(state), 396 | Transform2d.toJSON(this) 397 | ) 398 | } 399 | 400 | /** 401 | * Compares two shapes, usually used to sort the shapes for drawing 402 | * @param {BaseShape} shape1 403 | * @param {BaseShape} shape2 404 | * @return {number} Returns < 0 if shape1 < shape2, > 0 if shape1 > shape2, 0 if shape1 === shape2 405 | */ 406 | static shapeCompare(shape1, shape2) { 407 | const zIndex1 = shape1.zIndex 408 | const zIndex2 = shape2.zIndex 409 | if (zIndex1 < zIndex2) { 410 | return -1 411 | } else if (zIndex1 > zIndex2) { 412 | return 1 413 | } 414 | 415 | let rtn = FillStyle.compareFillStyle(shape1, shape2) 416 | if (!rtn) { 417 | rtn = StrokeStyle.compareStrokeStyle(shape1, shape2) 418 | } 419 | 420 | return rtn 421 | } 422 | } 423 | -------------------------------------------------------------------------------- /src/core/aabox2d.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | import { glMatrix } from "gl-matrix" 4 | import { EPSILON } from "./configure" 5 | import Vec2d from "./vec2d" 6 | 7 | export const MINX = 0 8 | export const MINY = 1 9 | export const MAXX = 2 10 | export const MAXY = 3 11 | 12 | const Constants = { 13 | BOX_SIDES: 4 14 | } 15 | 16 | /** 17 | * Sets the boundaries of an existing 2d axis-aligned bounding box 18 | * If arguments are not supplied, the aabox is initialized as empty. 19 | * @param {AABox2d} out AAbox2d to set 20 | * @param {Number} [minx = Infinity] Minimum x-axis value 21 | * @param {Number} [miny = Infinity] Minimum y-axis value 22 | * @param {Number} [maxx = -Infinity] Maximum x-axis value 23 | * @param {Number} [maxy = -Infinity] Maximum y-axis value 24 | * @return {AABox2d} bounds referenced by out arg 25 | */ 26 | export function set(out, minx, miny, maxx, maxy) { 27 | out[MINX] = typeof minx === "number" ? minx : Infinity 28 | out[MINY] = typeof miny === "number" ? miny : Infinity 29 | out[MAXX] = typeof maxx === "number" ? maxx : -Infinity 30 | out[MAXY] = typeof maxy === "number" ? maxy : -Infinity 31 | return out 32 | } 33 | 34 | /** 35 | * Creates a new 2d axis-aligned bounding box object 36 | * with optional boundaries. If boundaries are not supplied, 37 | * the aabox is initialized as empty. 38 | * @param {Number} [minx = Infinity] Minimum x-axis value 39 | * @param {Number} [miny = Infinity] Minimum y-axis value 40 | * @param {Number} [maxx = -Infinity] Maximum x-axis value 41 | * @param {Number} [maxy = -Infinity] Maximum y-axis value 42 | * @return {AABox2d} New AABox2d object 43 | */ 44 | export function create(minx, miny, maxx, maxy) { 45 | const out = new glMatrix.ARRAY_TYPE(Constants.BOX_SIDES) 46 | return set(out, minx, miny, maxx, maxy) 47 | } 48 | 49 | /** 50 | * Creates a new 2d axis-aligned bounding box with boundaries copied 51 | * from an existing aabox. 52 | * @param {AABox2d} box existing bounds to copy boundaries from 53 | * @return {AABox2d} new AABox2d object 54 | */ 55 | export function clone(box) { 56 | const out = new glMatrix.ARRAY_TYPE(Constants.BOX_SIDES) 57 | out[MINX] = box[MINX] 58 | out[MINY] = box[MINY] 59 | out[MAXX] = box[MAXX] 60 | out[MAXY] = box[MAXY] 61 | return out 62 | } 63 | 64 | /** 65 | * Copies the boundaries from one existing aabox to another. 66 | * @param {AABox2d} out bounds to copy to 67 | * @param {AABox2d} box bounds to copy from 68 | * @return {AABox2d} bounds referenced by out arg 69 | */ 70 | export function copy(out, box) { 71 | out[MINX] = box[MINX] 72 | out[MINY] = box[MINY] 73 | out[MAXX] = box[MAXX] 74 | out[MAXY] = box[MAXY] 75 | return out 76 | } 77 | 78 | /** 79 | * Re-initializes an existing aabox as empty. 80 | * An aabox is empty if the minimum value in either of 81 | * its dimensions exceeds its respective max value. 82 | * In this case, the minumums will be set to +Infinity 83 | * and the maximums to -Infinity 84 | * @param {AABox2d} out existing bounds to re-initialize as empty 85 | * @return {AABox2d} bounds referenced by out arg 86 | */ 87 | export function initEmpty(out) { 88 | out[MINX] = Infinity 89 | out[MINY] = Infinity 90 | out[MAXX] = -Infinity 91 | out[MAXY] = -Infinity 92 | return out 93 | } 94 | 95 | /** 96 | * Re-initializes an existing aabox to infinity, ultimately encompassing 97 | * all numeric values. 98 | * @param {AABox2d} out existing bounds to initialize to infinity 99 | * @return {AABox2d} bounds referenced by out arg 100 | */ 101 | export function initInfinity(out) { 102 | out[MINX] = -Infinity 103 | out[MINY] = -Infinity 104 | out[MAXX] = Infinity 105 | out[MAXY] = Infinity 106 | return out 107 | } 108 | 109 | /** 110 | * Initializes an existing aabox with its top-left corner set to be the origin ([0, 0]), 111 | * an extends outwards in each dimension by its respective size. 112 | * minx: 0 113 | * miny: 0 114 | * maxx: sizes[x] 115 | * maxy: sizes[y] 116 | * @param {AABox2d} out existing bounds to re-initialize 117 | * @param {Vec2d} sizes new width/height of the bounds 118 | * @return {AABox2d} bounds referenced by out arg 119 | */ 120 | export function initSizeFromOrigin(out, sizes) { 121 | if (sizes[0] < 0) { 122 | out[MINX] = -sizes[0] 123 | out[MAXX] = 0 124 | } else { 125 | out[MINX] = 0 126 | out[MAXX] = sizes[0] 127 | } 128 | if (sizes[1] < 0) { 129 | out[MINY] = -sizes[1] 130 | out[MAXY] = 0 131 | } else { 132 | out[MINY] = 0 133 | out[MAXY] = sizes[1] 134 | } 135 | return out 136 | } 137 | 138 | /** 139 | * Initializes an existing aabox with its top-left corner set to be an existing pt and with 140 | * bounds extending outwards in each dimension by its respective size. 141 | * minx: pt[x] 142 | * miny: pt[y] 143 | * maxx: pt[x] + sizes[x] 144 | * maxy: pt[y] + sizes[y] 145 | * @param {AABox2d} out existing bounds to re-initialize 146 | * @param {Point2d} pt new position of the top-left corner of the bounds 147 | * @param {Vec2d} sizes new width/height of the bounds 148 | * @return {AABox2d} bounds referenced by out arg 149 | */ 150 | export function initSizeFromLocation(out, pt, sizes) { 151 | for (let i = 0; i < 2; i += 1) { 152 | if (sizes[i] < 0) { 153 | out[i] = pt[i] - sizes[i] 154 | out[i + 2] = pt[i] 155 | } else { 156 | out[i] = pt[i] 157 | out[i + 2] = pt[i] + sizes[i] 158 | } 159 | } 160 | return out 161 | } 162 | 163 | /** 164 | * Initializes an existing aabox with its center set to a specific pt and with bounds 165 | * extending outward in each dimension so that the aabox's width and height are a 166 | * specific size 167 | * @param {AABox2d} out existing bounds to re-initialize 168 | * @param {Point2d} center new center of the bounds 169 | * @param {Vec2d} sizes new width/height of the bounds 170 | * @return {AABox2d} bounds referenced by out arg 171 | */ 172 | export function initCenterExtents(out, center, sizes) { 173 | for (let i = 0; i < 2; i += 1) { 174 | if (sizes[i] < 0) { 175 | out[i] = center[i] + sizes[i] 176 | out[i + 2] = center[i] - sizes[i] 177 | } else { 178 | out[i] = center[i] - sizes[i] 179 | out[i + 2] = center[i] + sizes[i] 180 | } 181 | } 182 | return out 183 | } 184 | 185 | /** 186 | * Returns true if the aabox is empty 187 | * @param {AABox2d} box 188 | * @return {Boolean} true if box is empty, false otherwise 189 | */ 190 | export function isEmpty(box) { 191 | return box[MINX] > box[MAXX] || box[MINY] > box[MAXY] 192 | } 193 | 194 | /** 195 | * Returns true if an aabox is infinite in either dimension 196 | * @param {AABox2d} box Existing aabox to check 197 | * @return {Boolean} True if box extends to +/- inifinity in either dimension, false otherwise 198 | */ 199 | export function isInfinite(box) { 200 | return ( 201 | !isFinite(box[MINX]) || 202 | !isFinite(box[MINY]) || 203 | !isFinite(box[MAXX]) || 204 | !isFinite(box[MAXY]) 205 | ) 206 | } 207 | 208 | /** 209 | * Returns true if one aabox approximately equals another 210 | * @param {AABox2d} a 211 | * @param {AABox2d} b 212 | * @param {Number} [epsilon=null] Optional epsilon value to use for the comparison. If null, uses 213 | * the globally-configured epsilon. 214 | * @return {Boolean} true if a ~= b 215 | */ 216 | export function equals(a, b, epsilon = null) { 217 | const a0 = a[0] 218 | const a1 = a[1] 219 | const a2 = a[2] 220 | const a3 = a[3] 221 | const b0 = b[0] 222 | const b1 = b[1] 223 | const b2 = b[2] 224 | const b3 = b[3] 225 | const eps = epsilon !== null ? epsilon : EPSILON 226 | return ( 227 | Math.abs(a0 - b0) <= eps && 228 | Math.abs(a1 - b1) <= eps && 229 | Math.abs(a2 - b2) <= eps && 230 | Math.abs(a3 - b3) <= eps 231 | ) 232 | } 233 | 234 | /** 235 | * Returns the width/height of an existing aabox 236 | * @param {Vec2d} out 2d vector to store the width/height of an existing aabox 237 | * @param {AABox2d} box bounds to extract the width/height from 238 | * @return {Vec2d} vector referenced by the out arg 239 | */ 240 | export function getSize(out, box) { 241 | return Vec2d.set(out, box[MAXX] - box[MINX], box[MAXY] - box[MINY]) 242 | } 243 | 244 | /** 245 | * Returns the extents of an existing aabox. 246 | * Extents is the size of a bounds in each dimension starting at the center 247 | * of the bounds. (i.e. extents = [width / 2, height / 2]) 248 | * @param {Vec2d} out 2d vector to store the extents of an existing aabox 249 | * @param {AABox2d} box bounds to extract the extents from 250 | * @return {Vec2d} vector referenced by the out arg 251 | */ 252 | export function getExtents(out, box) { 253 | getSize(out, box) 254 | return Vec2d.scale(out, out, 0.5) // eslint-disable-line no-magic-numbers 255 | } 256 | 257 | /** 258 | * Returns the center of an existing aabox 259 | * @param {Point2d} out point to store the center of an existing bounds 260 | * @param {AABox2d} box bounds to extract the center from 261 | * @return {Point2d} point referenced by the out arg 262 | */ 263 | export function getCenter(out, box) { 264 | getExtents(out, box) 265 | out[MINX] += box[MINX] 266 | out[MINY] += box[MINY] 267 | return out 268 | } 269 | 270 | /** 271 | * Expands an existing aabox by a specified size in each dimension. 272 | * @param {AABox2d} out bounds to store the resulting operation in 273 | * @param {AABox2d} box starting bounds to expand 274 | * @param {Vec2d} expandSize size to expand in each dimension 275 | * @return {AABox2d} bounds referenced by the out arg 276 | */ 277 | export function expand(out, box, expandSize) { 278 | out[MINX] = box[MINX] - expandSize[0] 279 | out[MAXX] = box[MAXX] + expandSize[0] 280 | out[MINY] = box[MINY] - expandSize[1] 281 | out[MAXY] = box[MAXY] + expandSize[1] 282 | } 283 | 284 | /** 285 | * Computes the area of an existing aabox 286 | * @param {AABox2d} box 287 | * @return {Number} area of the bounds 288 | */ 289 | export function area(box) { 290 | return (box[MAXX] - box[MINX]) * (box[MAXY] - box[MINY]) 291 | } 292 | 293 | /** 294 | * Calculates the hull of two aaboxes. The hull is the smallest bounds that contains 295 | * both of the aaboxes 296 | * @param {AABox2d} out bounds to store the resulting operation in 297 | * @param {AABox2d} a 298 | * @param {AABox2d} b 299 | * @return {AABox2d} bounds referenced by out arg 300 | */ 301 | export function hull(out, a, b) { 302 | return create( 303 | Math.min(a[MINX], b[MINX]), 304 | Math.min(a[MINY], b[MINY]), 305 | Math.max(a[MAXX], b[MAXX]), 306 | Math.max(a[MAXY], b[MAXY]) 307 | ) 308 | } 309 | 310 | /** 311 | * Calculates the intersection of two existing bounds. 312 | * @param {AABox2d} out bounds to store the resulting operation in 313 | * @param {AABox2d} a 314 | * @param {AABox2d} b 315 | * @return {AABox2d} bounds referenced by out arg 316 | */ 317 | export function intersection(out, a, b) { 318 | let boxToUse = out 319 | if (out === a) { 320 | boxToUse = create() 321 | } 322 | 323 | let minindex = MINX 324 | let maxindex = MAXX 325 | for (; minindex <= MINY; minindex += 1, maxindex += 1) { 326 | if (a[maxindex] < b[minindex] || a[minindex] > b[maxindex]) { 327 | break 328 | } 329 | 330 | boxToUse[minindex] = Math.max(a[minindex], b[minindex]) 331 | boxToUse[maxindex] = Math.min(a[maxindex], b[maxindex]) 332 | } 333 | 334 | if (minindex !== MINY + 1) { 335 | initEmpty(boxToUse) 336 | } 337 | 338 | if (out === a) { 339 | copy(out, boxToUse) 340 | } 341 | 342 | return out 343 | } 344 | 345 | /** 346 | * Returns true if one bounds overlaps another in any way (non-inclusive). 347 | * @param {AABox2d} a 348 | * @param {AABox2d} b 349 | * @return {Boolean} Returns true if a overlaps b, false otherwise 350 | */ 351 | export function overlaps(a, b) { 352 | return !( 353 | a[MAXX] <= b[MINX] || 354 | a[MINX] >= b[MAXX] || 355 | a[MAXY] <= b[MINY] || 356 | a[MINY] >= b[MAXY] 357 | ) 358 | } 359 | 360 | /** 361 | * Returns true if one bounds full contains another (inclusive). 362 | * @param {AABox2d} a 363 | * @param {AABox2d} b 364 | * @return {Boolean} true if a fully contains b. 365 | */ 366 | export function contains(a, b) { 367 | return !( 368 | b[MINX] < a[MINX] || 369 | b[MAXX] > a[MAXX] || 370 | b[MINY] < a[MINY] || 371 | b[MAXY] > a[MAXY] 372 | ) 373 | } 374 | 375 | /** 376 | * Returns true if an existing bounds contains a specific point (inclusive) 377 | * @param {AABox2d} box 378 | * @param {Point2d} pt 379 | * @return {Boolean} Returns true if pt is inside of box, false otherwise 380 | */ 381 | export function containsPt(box, pt) { 382 | return ( 383 | pt[MINX] >= box[MINX] && 384 | pt[MINX] <= box[MAXX] && 385 | pt[MINY] >= box[MINY] && 386 | pt[MINY] <= box[MAXY] 387 | ) 388 | } 389 | 390 | /** 391 | * Extends an existing bounds so that it would contain a specific point 392 | * @param {AABox2d} out Bounds containing the operation result 393 | * @param {AABox2d} box Starting bounds to possibly extend 394 | * @param {Point2d} pt Point to encapsulate in box 395 | * @return {AAbox2d} bounds referenced by out arg 396 | */ 397 | export function encapsulatePt(out, box, pt) { 398 | if (out !== box) { 399 | copy(out, box) 400 | } 401 | if (isEmpty(box)) { 402 | out[MINX] = pt[MINX] 403 | out[MAXX] = pt[MINX] 404 | out[MINY] = pt[MINY] 405 | out[MAXY] = pt[MINY] 406 | } else { 407 | if (pt[MINX] < out[MINX]) { 408 | out[MINX] = pt[MINX] 409 | } else if (pt[MINX] > out[MAXX]) { 410 | out[MAXX] = pt[MINX] 411 | } 412 | 413 | if (pt[MINY] < out[MINY]) { 414 | out[MINY] = pt[MINY] 415 | } else if (pt[MINY] > out[MAXY]) { 416 | out[MAXY] = pt[MINY] 417 | } 418 | } 419 | return out 420 | } 421 | 422 | /** 423 | * Translates an existing bounds by a specified offset it each dimension 424 | * @param {AABox2d} out bounds resulting from the operation 425 | * @param {AABox2d} box starting bounds 426 | * @param {Vec2d} pos translation in each dimension 427 | * @return {AABox2d} bounds referenced by out arg 428 | */ 429 | export function translate(out, box, pos) { 430 | out[MINX] = box[MINX] + pos[0] 431 | out[MINY] = box[MINY] + pos[1] 432 | out[MAXX] = box[MAXX] + pos[0] 433 | out[MAXY] = box[MAXY] + pos[1] 434 | } 435 | 436 | function transform(out, box, mat, xformFunc) { 437 | let boxToUse = out 438 | if (out === box) { 439 | boxToUse = create() 440 | } 441 | initEmpty(boxToUse) 442 | 443 | const pt1 = Vec2d.set(Vec2d.create(), box[MINX], box[MINY]) 444 | const pt2 = Vec2d.create() 445 | xformFunc(pt2, pt1, mat) 446 | encapsulatePt(boxToUse, boxToUse, pt2) 447 | pt1[MINX] = box[MAXX] 448 | xformFunc(pt2, pt1, mat) 449 | encapsulatePt(boxToUse, boxToUse, pt2) 450 | pt1[MINY] = box[MAXY] 451 | xformFunc(pt2, pt1, mat) 452 | encapsulatePt(boxToUse, boxToUse, pt2) 453 | pt1[MINX] = box[MINX] 454 | xformFunc(pt2, pt1, mat) 455 | encapsulatePt(boxToUse, boxToUse, pt2) 456 | if (out === box) { 457 | copy(out, boxToUse) 458 | } 459 | return out 460 | } 461 | 462 | /** 463 | * Transforms an existing bounds by a 2x2 matrix 464 | * @param {AABox2d} out bounds to contain the operation result 465 | * @param {AABox2d} box bounds to transform 466 | * @param {Mat2} mat 2x2 matrix transformation 467 | * @return {AABox2d} bounds referenced by out arg 468 | */ 469 | export function transformMat2(out, box, mat) { 470 | return transform(out, box, mat, Vec2d.transformMat2) 471 | } 472 | 473 | /** 474 | * Transforms an existing bounds by a 2x3 matrix. 475 | * A 2x3 matrix is a 2x2 matrix with a translation component. 476 | * @param {AABox2d} out bounds to hold the operation result 477 | * @param {AABox2d} box bounds to transform 478 | * @param {Mat2d} mat 2x3 matrix 479 | * @return {AABox2d} bounds referenced by out arg 480 | */ 481 | export function transformMat2d(out, box, mat) { 482 | return transform(out, box, mat, Vec2d.transformMat2d) 483 | } 484 | -------------------------------------------------------------------------------- /src/style/color-rgba.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-magic-numbers */ 2 | "use strict" 3 | 4 | import Math from "../math/math" 5 | 6 | /** 7 | * Color keywords as defined by the CSS color modules 8 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/CSS/color_value} 9 | * @type {Map} 10 | */ 11 | const colorKeywords = new Map([ 12 | ["aliceblue", "#F0F8FF"], 13 | ["antiquewhite", "#FAEBD7"], 14 | ["aqua", "#00FFFF"], 15 | ["aquamarine", "#7FFFD4"], 16 | ["azure", "#F0FFFF"], 17 | ["beige", "#F5F5DC"], 18 | ["bisque", "#FFE4C4"], 19 | ["black", "#000000"], 20 | ["blanchedalmond", "#FFEBCD"], 21 | ["blue", "#0000FF"], 22 | ["blueviolet", "#8A2BE2"], 23 | ["brown", "#A52A2A"], 24 | ["burlywood", "#DEB887"], 25 | ["cadetblue", "#5F9EA0"], 26 | ["chartreuse", "#7FFF00"], 27 | ["chocolate", "#D2691E"], 28 | ["coral", "#FF7F50"], 29 | ["cornflowerblue", "#6495ED"], 30 | ["cornsilk", "#FFF8DC"], 31 | ["crimson", "#DC143C"], 32 | ["cyan", "#00FFFF"], 33 | ["darkblue", "#00008B"], 34 | ["darkcyan", "#008B8B"], 35 | ["darkgoldenrod", "#B8860B"], 36 | ["darkgray", "#A9A9A9"], 37 | ["darkgreen", "#006400"], 38 | ["darkkhaki", "#BDB76B"], 39 | ["darkmagenta", "#8B008B"], 40 | ["darkolivegreen", "#556B2F"], 41 | ["darkorange", "#FF8C00"], 42 | ["darkorchid", "#9932CC"], 43 | ["darkred", "#8B0000"], 44 | ["darksalmon", "#E9967A"], 45 | ["darkseagreen", "#8FBC8F"], 46 | ["darkslateblue", "#483D8B"], 47 | ["darkslategray", "#2F4F4F"], 48 | ["darkturquoise", "#00CED1"], 49 | ["darkviolet", "#9400D3"], 50 | ["deeppink", "#FF1493"], 51 | ["deepskyblue", "#00BFFF"], 52 | ["dimgray", "#696969"], 53 | ["dodgerblue", "#1E90FF"], 54 | ["firebrick", "#B22222"], 55 | ["floralwhite", "#FFFAF0"], 56 | ["forestgreen", "#228B22"], 57 | ["fuchsia", "#FF00FF"], 58 | ["gainsboro", "#DCDCDC"], 59 | ["ghostwhite", "#F8F8FF"], 60 | ["gold", "#FFD700"], 61 | ["goldenrod", "#DAA520"], 62 | ["gray", "#808080"], 63 | ["green", "#008000"], 64 | ["greenyellow", "#ADFF2F"], 65 | ["honeydew", "#F0FFF0"], 66 | ["hotpink", "#FF69B4"], 67 | ["indianred", "#CD5C5C"], 68 | ["indigo", "#4B0082"], 69 | ["ivory", "#FFFFF0"], 70 | ["khaki", "#F0E68C"], 71 | ["lavender", "#E6E6FA"], 72 | ["lavenderblush", "#FFF0F5"], 73 | ["lawngreen", "#7CFC00"], 74 | ["lemonchiffon", "#FFFACD"], 75 | ["lightblue", "#ADD8E6"], 76 | ["lightcoral", "#F08080"], 77 | ["lightcyan", "#E0FFFF"], 78 | ["lightgoldenrodyellow", "#FAFAD2"], 79 | ["lightgray", "#D3D3D3"], 80 | ["lightgreen", "#90EE90"], 81 | ["lightpink", "#FFB6C1"], 82 | ["lightsalmon", "#FFA07A"], 83 | ["lightseagreen", "#20B2AA"], 84 | ["lightskyblue", "#87CEFA"], 85 | ["lightslategray", "#778899"], 86 | ["lightsteelblue", "#B0C4DE"], 87 | ["lightyellow", "#FFFFE0"], 88 | ["lime", "#00FF00"], 89 | ["limegreen", "#32CD32"], 90 | ["linen", "#FAF0E6"], 91 | ["magenta", "#FF00FF"], 92 | ["maroon", "#800000"], 93 | ["mediumaquamarine", "#66CDAA"], 94 | ["mediumblue", "#0000CD"], 95 | ["mediumorchid", "#BA55D3"], 96 | ["mediumpurple", "#9370DB"], 97 | ["mediumseagreen", "#3CB371"], 98 | ["mediumslateblue", "#7B68EE"], 99 | ["mediumspringgreen", "#00FA9A"], 100 | ["mediumturquoise", "#48D1CC"], 101 | ["mediumvioletred", "#C71585"], 102 | ["midnightblue", "#191970"], 103 | ["mintcream", "#F5FFFA"], 104 | ["mistyrose", "#FFE4E1"], 105 | ["moccasin", "#FFE4B5"], 106 | ["navajowhite", "#FFDEAD"], 107 | ["navy", "#000080"], 108 | ["oldlace", "#FDF5E6"], 109 | ["olive", "#808000"], 110 | ["olivedrab", "#6B8E23"], 111 | ["orange", "#FFA500"], 112 | ["orangered", "#FF4500"], 113 | ["orchid", "#DA70D6"], 114 | ["palegoldenrod", "#EEE8AA"], 115 | ["palegreen", "#98FB98"], 116 | ["paleturquoise", "#AFEEEE"], 117 | ["palevioletred", "#DB7093"], 118 | ["papayawhip", "#FFEFD5"], 119 | ["peachpuff", "#FFDAB9"], 120 | ["peru", "#CD853F"], 121 | ["pink", "#FFC0CB"], 122 | ["plum", "#DDA0DD"], 123 | ["powderblue", "#B0E0E6"], 124 | ["purple", "#800080"], 125 | ["rebeccapurple", "#663399"], 126 | ["red", "#FF0000"], 127 | ["rosybrown", "#BC8F8F"], 128 | ["royalblue", "#4169E1"], 129 | ["saddlebrown", "#8B4513"], 130 | ["salmon", "#FA8072"], 131 | ["sandybrown", "#F4A460"], 132 | ["seagreen", "#2E8B57"], 133 | ["seashell", "#FFF5EE"], 134 | ["sienna", "#A0522D"], 135 | ["silver", "#C0C0C0"], 136 | ["skyblue", "#87CEEB"], 137 | ["slateblue", "#6A5ACD"], 138 | ["slategray", "#708090"], 139 | ["snow", "#FFFAFA"], 140 | ["springgreen", "#00FF7F"], 141 | ["steelblue", "#4682B4"], 142 | ["tan", "#D2B48C"], 143 | ["teal", "#008080"], 144 | ["thistle", "#D8BFD8"], 145 | ["tomato", "#FF6347"], 146 | ["turquoise", "#40E0D0"], 147 | ["violet", "#EE82EE"], 148 | ["wheat", "#F5DEB3"], 149 | ["white", "#FFFFFF"], 150 | ["whitesmoke", "#F5F5F5"], 151 | ["yellow", "#FFFF00"], 152 | ["yellowgreen", "#9ACD32"] 153 | ]) 154 | 155 | /** 156 | * rgb regex to handle "rgb([0-255],[0-255],[0-255])" color strings 157 | * @type {RegExp} 158 | */ 159 | const rgbRegex = /^rgb\s*\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)\s*$/i 160 | 161 | /** 162 | * rgba regex to handle "rgba([0-255],[0-255],[0-255],[0.0-1.0])" color strings 163 | * @type {RegExp} 164 | */ 165 | const rgbaRegex = /^rgba\s*\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*([0,1](?:\.\d*)?)\s*\)\s*$/i 166 | 167 | /** 168 | * hex regex to handle "#[00-FF][00-FF][00-FF]" color strings. 169 | * @type {RegExp} 170 | */ 171 | const hexRegex = /^#([0-9,a-f,A-F]{2})([0-9,a-f,A-F]{2})([0-9,a-f,A-F]{2})\s*$/i 172 | 173 | /** 174 | * Extracts an opacity from an rgba color packed into a 32-bit integer 175 | * @param {number} packedRgbaColor 176 | * @return {number} opacity extracted in range of [0,1] 177 | */ 178 | function getOpacity(packedRgbaColor) { 179 | return (packedRgbaColor & 0xff) / 255.0 180 | } 181 | 182 | /** 183 | * packs an opacity value [0,1] into an 8-bit integer to be packed 184 | * into a 32-bit int 185 | * @param {number} opacity [0,1] 186 | * @return {number} [0,255] 187 | */ 188 | function packOpacity(opacity) { 189 | return Math.floor(Math.clamp01(opacity) * 255) 190 | } 191 | 192 | /** 193 | * Given a color packed into a 32-bit integer, returns a css-style "rgba()" string 194 | * @param {number} packedRgbaColor 195 | * @return {string} int the form "rgba([0,255],[0,255],[0,255],[0,1])" 196 | */ 197 | function getRGBAString(packedRgbaColor) { 198 | return `rgba(${packedRgbaColor >>> 24}, ${(packedRgbaColor & 0xff0000) >> 199 | 16}, ${(packedRgbaColor & 0xff00) >> 8}, ${getOpacity(packedRgbaColor)})` 200 | } 201 | 202 | /** 203 | * Given an rgba() color string, extracts a numeric color packed into a 32-bit int 204 | * @param {string} color 205 | * @return {number} 206 | */ 207 | function getPackedColorRGBAFromString(color) { 208 | if (typeof color !== "string") { 209 | throw new Error(`${color} is not a valid color string`) 210 | } 211 | 212 | let packedColor = 0 213 | 214 | let match = null 215 | if ((match = color.match(rgbRegex))) { 216 | packedColor = 255 // (fully opaque) 217 | packedColor |= Math.clamp(Number.parseInt(match[3], 10), 0, 255) << 8 218 | packedColor |= Math.clamp(Number.parseInt(match[2], 10), 0, 255) << 16 219 | packedColor |= Math.clamp(Number.parseInt(match[1], 10), 0, 255) << 24 220 | } else if ((match = color.match(rgbaRegex))) { 221 | packedColor = packOpacity(Number.parseFloat(match[4], 10)) 222 | packedColor |= Math.clamp(Number.parseInt(match[3], 10), 0, 255) << 8 223 | packedColor |= Math.clamp(Number.parseInt(match[2], 10), 0, 255) << 16 224 | packedColor |= Math.clamp(Number.parseInt(match[1], 10), 0, 255) << 24 225 | } else if ((match = color.match(hexRegex))) { 226 | packedColor = 255 // (fully opaque) 227 | packedColor |= Math.clamp(Number.parseInt(match[3], 16), 0, 255) << 8 228 | packedColor |= Math.clamp(Number.parseInt(match[2], 16), 0, 255) << 16 229 | packedColor |= Math.clamp(Number.parseInt(match[1], 16), 0, 255) << 24 230 | } else if (colorKeywords.has(color)) { 231 | match = colorKeywords.get(color).match(hexRegex) 232 | packedColor = 255 // (fully opaque) 233 | packedColor |= Math.clamp(Number.parseInt(match[3], 16), 0, 255) << 8 234 | packedColor |= Math.clamp(Number.parseInt(match[2], 16), 0, 255) << 16 235 | packedColor |= Math.clamp(Number.parseInt(match[1], 16), 0, 255) << 24 236 | } else { 237 | throw new Error(`${color} is not a valid color string`) 238 | } 239 | 240 | return packedColor 241 | } 242 | 243 | /** @class manages colors in the RGBA color space. Can also be used as a mixin */ 244 | export default class ColorRGBA { 245 | /** 246 | * Creates a new color in the RGBA color space 247 | * @param {string} initColorStr color string in the form "rgb()", "rgba()", "#......", or a color keyword (i.e. "red") 248 | * @param {...object} args Additional arguments that may be passed to other initializers/constructors 249 | * if this class is used as a base class or mixin 250 | * @return {ColorRGBA} 251 | */ 252 | constructor(initColorStr, ...args) { 253 | this.initializer(initColorStr, ...args) 254 | } 255 | 256 | /** 257 | * initializes a color in the RGBA color space using a color string 258 | * @param {string} initColorStr initializer string in the form "rgb()", "rgba()", "#......", or color keyword 259 | */ 260 | initializer(initColorStr) { 261 | this._color = 0 262 | if (initColorStr) { 263 | this.value = initColorStr 264 | } 265 | } 266 | 267 | /** 268 | * sets all the channels of the RGBA color given a color string 269 | * @param {string} color color string in the form "rgb()", "rgba()", "#......", or color keyword 270 | * @return {ColorRGBA} 271 | */ 272 | set value(color) { 273 | this._color = getPackedColorRGBAFromString(color) 274 | return this 275 | } 276 | 277 | /** 278 | * Gets the rgba color as a color string "rgba()" 279 | * @return {string} rgba color as a string 280 | */ 281 | get value() { 282 | return getRGBAString(this._color) 283 | } 284 | 285 | /** 286 | * Sets the value of the RGBA color packed as a 32-bit int 287 | * @param {number} packedColor 288 | * @return {ColorRGBA} 289 | */ 290 | set packedValue(packedColor) { 291 | this._color = 0 292 | this._color |= packedColor 293 | return this 294 | } 295 | 296 | /** 297 | * Gets the current value of the RGBA color as a packed 32-bit int 298 | * @return {number} 299 | */ 300 | get packedValue() { 301 | return this._color 302 | } 303 | 304 | /** 305 | * sets the opacity of the RGBA color (modifies alpha channel only) 306 | * @param {number} opacity [0,1] 307 | * @return {ColorRGBA} 308 | */ 309 | set opacity(opacity) { 310 | if (typeof opacity !== "number") { 311 | throw new Error("Opacity must be a number between 0-1") 312 | } 313 | 314 | const currOpacity = this._color & 0xff 315 | const newOpacity = packOpacity(opacity) 316 | if (newOpacity !== currOpacity) { 317 | this._color &= 0xffffff00 318 | this._color |= newOpacity 319 | } 320 | return this 321 | } 322 | 323 | /** 324 | * Gets the current opacity (alpha channel) of the RGBA color 325 | * @return {number} [0,1] 326 | */ 327 | get opacity() { 328 | return getOpacity(this._color) 329 | } 330 | 331 | /** 332 | * Returns true if the current opacity of the rgba color < 1 333 | * @return {Boolean} 334 | */ 335 | isTransparent() { 336 | return getOpacity(this._color) < 1.0 337 | } 338 | } 339 | 340 | /** 341 | * Creates a new color RGBA class that fires events whenever the color 342 | * is changed externally 343 | * @param {string} eventName Event type to fire when color is modified 344 | * @param {string} colorName Name of the color attribute. This string is used in the event object fired 345 | * @return {function} New class constructor function 346 | */ 347 | export function createEventedColorRGBAClass(eventName, colorName) { 348 | /** 349 | * @class New rgba color class that fires events when modified 350 | * @extends {ColorRGBA} 351 | */ 352 | return class EventedColorRGBA extends ColorRGBA { 353 | /** 354 | * Color modification event 355 | * @event EventedColor#changed 356 | * @type {object} 357 | * @property {string} attr Name of the attribute modified 358 | * @property {} prevVal Previous value of the attribute prior to modification 359 | * @property {} currVal Value of the attribute post modification 360 | */ 361 | 362 | /** 363 | * Initializes the evented color 364 | * @param {string} initColorStr initial color as string 365 | * @param {EventHandler} eventHandler Event handler instance used to manage the color 366 | * modification events 367 | * @protected 368 | */ 369 | initializer(initColorStr, eventHandler) { 370 | this._eventHandler = eventHandler 371 | super.initializer(initColorStr) 372 | } 373 | 374 | /** 375 | * Validates modification of the rgba color, and if modified, fires 376 | * modification events 377 | * @param {number} newPackedColor Color defined by a 32-bit int 378 | * @private 379 | */ 380 | _checkPackedColorChanged(newPackedColor) { 381 | if (newPackedColor !== this._color) { 382 | const prev = this._color 383 | const prevOpacity = getOpacity(prev) 384 | const opacity = getOpacity(newPackedColor) 385 | this._color = newPackedColor 386 | 387 | this._eventHandler.fire(eventName, { 388 | attr: colorName, 389 | prevVal: getRGBAString(prev), 390 | currVal: getRGBAString(this._color) 391 | }) 392 | 393 | if (opacity !== prevOpacity) { 394 | this._eventHandler.fire(eventName, { 395 | attr: "opacity", 396 | prevVal: prevOpacity, 397 | currVal: opacity 398 | }) 399 | } 400 | } 401 | } 402 | 403 | /** 404 | * sets all the channels of the RGBA color from a color string 405 | * @param {string} color color string in the form "rgb()", "rgba()", "#......", or color keyword 406 | * @fires EventedColor#changed 407 | * @return {ColorRGBA} 408 | */ 409 | set value(color) { 410 | const tmpcolor = getPackedColorRGBAFromString(color) 411 | this._checkPackedColorChanged(tmpcolor) 412 | return this 413 | } 414 | 415 | /** 416 | * Gets the rgba color as a color string "rgba()" 417 | * @return {string} rgba color as a string 418 | */ 419 | get value() { 420 | return getRGBAString(this._color) 421 | } 422 | 423 | /** 424 | * Sets the value of the RGBA color packed as a 32-bit int 425 | * @param {number} packedColor 426 | * @fires EventedColor#changed 427 | * @return {ColorRGBA} 428 | */ 429 | set packedValue(packedColor) { 430 | let tmpcolor = 0 431 | tmpcolor |= packedColor 432 | this._checkPackedColorChanged(tmpcolor) 433 | return this 434 | } 435 | 436 | /** 437 | * Gets the current value of the RGBA color as a packed 32-bit int 438 | * @return {number} 439 | */ 440 | get packedValue() { 441 | return this._color 442 | } 443 | 444 | /** 445 | * sets the opacity of the RGBA color (modifies alpha channel only) 446 | * @param {number} opacity [0,1] 447 | * @fires EventedColor#changed 448 | * @return {ColorRGBA} 449 | */ 450 | set opacity(opacity) { 451 | if (typeof opacity !== "number") { 452 | throw new Error("Opacity must be a number between 0-1") 453 | } 454 | 455 | const currOpacity = this._color & 0xff 456 | const newOpacity = packOpacity(opacity) 457 | if (newOpacity !== currOpacity) { 458 | this._color &= 0xffffff00 459 | this._color |= newOpacity 460 | 461 | this._eventHandler.fire(eventName, { 462 | attr: "opacity", 463 | prevVal: currOpacity / 255.0, 464 | currVal: newOpacity / 255.0 465 | }) 466 | } 467 | return this 468 | } 469 | 470 | /** 471 | * Gets the current opacity (alpha channel) of the RGBA color 472 | * @return {number} [0,1] 473 | */ 474 | get opacity() { 475 | return getOpacity(this._color) 476 | } 477 | } 478 | } 479 | -------------------------------------------------------------------------------- /src/shapes/poly-line.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-magic-numbers */ 2 | "use strict" 3 | 4 | import * as AABox2d from "../core/aabox2d" 5 | import * as Point2d from "../core/point2d" 6 | import Mat2d from "../core/mat2d" 7 | import BaseShape from "./base-shape.js" 8 | import Math from "../math/math" 9 | import { simpleHull_2D } from "../math/convex-hull" 10 | 11 | const identityMatrix = Mat2d.create() 12 | 13 | /** 14 | * Expands an axis-aligned bounding box to encapsulate a 2d vertex 15 | * defined in an array of vertices, and updates a struct used to 16 | * store the indices of the vertices that define the final bounds 17 | * of the vertices 18 | * @param {AABox2d} box Bounds to expand 19 | * @param {Point2d} pt 2d vertex to encapsulate 20 | * @param {number} ptIdx Index of the vertex in its list of vertices 21 | * @param {number[]} extentIndices Struct to store the indices of the bounding vertices 22 | * @private 23 | */ 24 | function aaboxEncapsulatePt(box, pt, ptIdx, extentIndices) { 25 | if (AABox2d.isEmpty(box)) { 26 | box[0] = pt[0] 27 | box[2] = pt[0] 28 | extentIndices[0] = extentIndices[2] = ptIdx 29 | box[1] = pt[1] 30 | box[3] = pt[1] 31 | extentIndices[1] = extentIndices[3] = ptIdx 32 | } else { 33 | if (pt[0] < box[0]) { 34 | box[0] = pt[0] 35 | extentIndices[0] = ptIdx 36 | } else if (pt[0] > box[2]) { 37 | box[2] = pt[0] 38 | extentIndices[2] = ptIdx 39 | } 40 | 41 | if (pt[1] < box[1]) { 42 | box[1] = pt[1] 43 | extentIndices[1] = ptIdx 44 | } else if (pt[1] > box[3]) { 45 | box[3] = pt[1] 46 | extentIndices[3] = ptIdx 47 | } 48 | } 49 | } 50 | 51 | /** 52 | * Function called sequentially to calculate the centroid of a polygon 53 | * @param {Point2d} centroidPt Current centroid point 54 | * @param {Point2d} pt1 Point describing one end of an edge of a polygon 55 | * @param {Point2d} pt2 Point describing other end of an edge of a polygon 56 | * @private 57 | */ 58 | function buildCentroid(centroidPt, pt1, pt2) { 59 | const a = pt1[0] * pt2[1] - pt2[0] * pt1[1] 60 | centroidPt[0] += (pt1[0] + pt2[0]) * a 61 | centroidPt[1] += (pt1[1] + pt2[1]) * a 62 | return a 63 | } 64 | 65 | /** 66 | * Utility function used to check whether an argument is an array 67 | * of an arraybuffer 68 | * @param {} obj 69 | * @return {Boolean} Returns true if object is array-like 70 | */ 71 | function isArray(obj) { 72 | return Array.isArray(obj) || (ArrayBuffer && ArrayBuffer.isView(obj)) 73 | } 74 | 75 | /** 76 | * @typedef {object} PolyLineOptions 77 | * @property {number[]|Point2d[]} [verts=[]] Initial vertices of the polyline 78 | */ 79 | 80 | /** 81 | * @class Class defining a poly line 82 | * @extends {BaseShape} 83 | */ 84 | export default class PolyLine extends BaseShape { 85 | /** 86 | * Creates a new poly line shape 87 | * @param {PolyLineOptions} [opts] [description] 88 | * @return {PolyLine} 89 | */ 90 | constructor(opts) { 91 | const verts = opts.verts || [] 92 | super(opts) 93 | if ( 94 | !isArray(verts) || 95 | verts.length === 0 || 96 | (isArray(verts[0]) && verts.length < 1) || 97 | (!isArray(verts[0]) && (verts.length < 2 || verts.length % 2 !== 0)) 98 | ) { 99 | throw new Error( 100 | "PolyLine shapes must be initialized with an array of 2d points and contain at least 1 points" 101 | ) 102 | } 103 | 104 | // going to build the aabox and store the indices for each vertex 105 | // that defines the bounds 106 | this._extentIndices = [-1, -1, -1, -1] 107 | this._localaabox = AABox2d.create() 108 | this._verts = [] 109 | this._centroid = Point2d.create() 110 | AABox2d.initEmpty(this._aabox) 111 | let signedArea = 0 112 | let i = 0 113 | if (isArray(verts[0])) { 114 | for (i = 0; i < verts.length - 1; i += 1) { 115 | this._verts.push(Point2d.clone(verts[i])) 116 | aaboxEncapsulatePt(this._aabox, verts[i], i, this._extentIndices) 117 | signedArea += buildCentroid(this._centroid, verts[i], verts[i + 1]) 118 | } 119 | this._verts.push(Point2d.clone(verts[i])) 120 | aaboxEncapsulatePt(this._aabox, verts[i], i, this._extentIndices) 121 | signedArea += buildCentroid(this._centroid, verts[i], verts[0]) 122 | } else { 123 | this._verts.push(Point2d.create(verts[0], verts[1])) 124 | aaboxEncapsulatePt(this._aabox, this._verts[0], 0, this._extentIndices) 125 | let idx = 1 126 | for (i = 2; i < verts.length - 2; i += 2, idx += 1) { 127 | this._verts.push(Point2d.create(verts[i], verts[i + 1])) 128 | aaboxEncapsulatePt( 129 | this._aabox, 130 | this._verts[idx], 131 | idx, 132 | this._extentIndices 133 | ) 134 | signedArea += buildCentroid( 135 | this._centroid, 136 | this._verts[idx - 1], 137 | this._verts[idx] 138 | ) 139 | } 140 | this._verts.push(Point2d.create(verts[i], verts[i + 1])) 141 | aaboxEncapsulatePt( 142 | this._aabox, 143 | this._verts[idx], 144 | idx, 145 | this._extentIndices 146 | ) 147 | signedArea += buildCentroid( 148 | this._centroid, 149 | this._verts[idx], 150 | this._verts[0] 151 | ) 152 | } 153 | signedArea *= 0.5 154 | this._centroid[0] /= 6.0 * signedArea 155 | this._centroid[1] /= 6.0 * signedArea 156 | 157 | // extract the center of the aabox. We are going to use this as the 158 | // shape's pivot, so all rotation/scale transformations will be sourced 159 | // at this location 160 | const pivot = Point2d.create() 161 | AABox2d.getCenter(pivot, this._aabox) 162 | this.pivot = pivot 163 | 164 | // now build the convex hull of the vertices. 165 | // When rebuilding the axis aligned box (a transform is applied for example), 166 | // there's no need to traverse all the points. All we need to do is traverse 167 | // the points that define the convex hull to rebuild the bounds 168 | if (this._verts.length < 3) { 169 | this._convexHull = this._verts.map((val, idx) => idx) 170 | } else { 171 | this._convexHull = simpleHull_2D(this._verts) 172 | } 173 | } 174 | 175 | /** 176 | * Get the untransformed width/height of the polyline. This is essentially 177 | * the width/height of the poly's bounds 178 | * @return {Vec2d} Width/height of the bounds of the polyline 179 | */ 180 | getDimensions() { 181 | return [this.width, this.height] 182 | } 183 | 184 | /** 185 | * Get the untransformed width of the polyline. This is the width of the 186 | * axis-aligned bounds of the poly 187 | * @return {number} Width of the poly in world-space units 188 | */ 189 | get width() { 190 | this._updateAABox() 191 | if ( 192 | !this._verts.length || 193 | this._extentIndices[0] < 0 || 194 | this._extentIndices[2] < 0 195 | ) { 196 | return 0 197 | } 198 | 199 | return ( 200 | this._verts[this._extentIndices[2]][0] - 201 | this._verts[this._extentIndices[0]][0] 202 | ) 203 | } 204 | 205 | /** 206 | * Gets the untransformed height of the polyline. This is the height of the axis-aligned 207 | * bounds of the poly 208 | * @return {number} Height of the poly in world-space units 209 | */ 210 | get height() { 211 | this._updateAABox() 212 | if ( 213 | !this._verts.length || 214 | this._extentIndices[0] < 0 || 215 | this._extentIndices[2] < 0 216 | ) { 217 | return 0 218 | } 219 | 220 | return ( 221 | this._verts[this._extentIndices[3]][1] - 222 | this._verts[this._extentIndices[1]][1] 223 | ) 224 | } 225 | 226 | /** 227 | * Gets a reference to the vertex array of the polyline 228 | * @return {Point2d[]} 229 | * @readOnly 230 | */ 231 | get vertsRef() { 232 | return this._verts 233 | } 234 | 235 | /** 236 | * Gets the number of vertices in the polyline 237 | * @return {number} 238 | */ 239 | get numVerts() { 240 | return this._verts.length 241 | } 242 | 243 | /** 244 | * Utility function that collapses all the verts, meaning the verts 245 | * are flattened to their position with local-space transforms applied 246 | * and then the local transforms are cleared. This is done whenever 247 | * the vertices of the polygon are modified as it can be a little tricky 248 | * to re-adjust the pivot/transforms of the vert when new verts are added, 249 | * deleted, etc. 250 | * @return {boolean} Returns true if the points were indeed flattened 251 | * The points wouldn't be flattened if there are no 252 | * transforms to apply, for example 253 | * @private 254 | */ 255 | _collapseVerts() { 256 | Point2d.set(this._pivot, 0, 0) 257 | 258 | // TODO(croot): what if this poly is 259 | // parented to another transform? 260 | 261 | const xform = this.localXform 262 | if (Mat2d.equals(xform, identityMatrix)) { 263 | // if there are no transforms to apply, 264 | // do nothing - fast out 265 | return false 266 | } 267 | 268 | AABox2d.initEmpty(this._aabox) 269 | 270 | // flatten all the points to their current world-space position 271 | // with transforms applied 272 | for (let i = 0; i < this._verts.length; i += 1) { 273 | Point2d.transformMat2d(this._verts[i], this._verts[i], xform) 274 | } 275 | 276 | // now recalcute the convex hull of all the transformed points 277 | if (this._verts.length < 3) { 278 | this._convexHull = this._verts.map((val, idx) => idx) 279 | } else { 280 | this._convexHull = simpleHull_2D(this._verts) 281 | } 282 | // use the convex hull points to rebuild the bounds 283 | this._convexHull.forEach(idx => { 284 | aaboxEncapsulatePt( 285 | this._aabox, 286 | this._verts[idx], 287 | idx, 288 | this._extentIndices 289 | ) 290 | }) 291 | 292 | // reset the local transforms 293 | this.setTransformations(0, 0, 1, 1, 0) 294 | 295 | return true 296 | } 297 | 298 | /** 299 | * Translates a specific vertex of the polygon by an offset 300 | * @param {number} vertIndex Index of the vertex to translate 301 | * @param {Vec2d} t Translation offset, in world-space units 302 | * @return {PolyLine} this 303 | * @fires {Shape#geomChanged} 304 | * @throws {Error} If vertIndex is invalid. 305 | */ 306 | translateVert(vertIndex, t) { 307 | if (vertIndex >= this._verts.length) { 308 | throw new Error( 309 | `Cannot translate vertex at index ${vertIndex}. There are only ${this._verts.length} vertices in the polygon.` 310 | ) 311 | } 312 | 313 | if (t[0] || t[1]) { 314 | const prev = Point2d.clone(this._verts[vertIndex]) 315 | const newPt = Point2d.clone(this._verts[vertIndex]) 316 | Point2d.addVec2(newPt, newPt, t) 317 | 318 | // TODO(croot): this could be made smarter by determining whether 319 | // this point affects the convex hull or not by checking it's relationship 320 | // with its neighbors 321 | this._collapseVerts() 322 | this._resetAABox = true 323 | this._geomDirty = true 324 | Point2d.copy(newPt) 325 | this.fire("changed:geom", { 326 | attr: `verts[${vertIndex}]`, 327 | prevVal: prev, 328 | currVal: newPt 329 | }) 330 | } 331 | return this 332 | } 333 | 334 | setVertPosition(vertIndex, pos) { 335 | if (vertIndex >= this._verts.length) { 336 | throw new Error( 337 | `Cannot translate vertex at index ${vertIndex}. There are only ${this._verts.length} vertices in the polygon.` 338 | ) 339 | } 340 | 341 | if (!Point2d.equals(pos, this._verts[vertIndex])) { 342 | const prev = Point2d.clone(this._verts[vertIndex]) 343 | this._collapseVerts() 344 | Point2d.copy(this._verts[vertIndex], pos) 345 | 346 | // TODO(croot): this could be made smarter by determining whether 347 | // this point affects the convex hull or not by checking it's relationship 348 | // with its neighbors 349 | this._resetAABox = true 350 | this._geomDirty = true 351 | this.fire("changed:geom", { 352 | attr: `verts[${vertIndex}]`, 353 | prevVal: prev, 354 | currVal: pos 355 | }) 356 | } 357 | } 358 | 359 | insertVert(vertIndex, pos) { 360 | let idx = Math.min(Math.max(vertIndex, 0), this._verts.length) 361 | this._collapseVerts() 362 | if (vertIndex >= this._verts.length) { 363 | this._verts.push(Point2d.clone(pos)) 364 | idx = this._verts.length - 1 365 | } else { 366 | this._verts.splice(vertIndex, 0, Point2d.clone(pos)) 367 | } 368 | this._resetAABox = true 369 | this._geomDirty = true 370 | 371 | this.fire("changed:geom:addvert", { 372 | attr: `verts[${idx}]`, 373 | currVal: pos 374 | }) 375 | 376 | return idx 377 | } 378 | 379 | appendVert(pos) { 380 | return this.insertVert(this._verts.length, pos) 381 | } 382 | 383 | removeVert(vertIndex) { 384 | if (vertIndex >= this._verts.length || vertIndex < 0) { 385 | throw new Error( 386 | `Cannot remove vertex ${vertIndex}. Invalid index. There are only ${this._verts.length} vertices in the shape.` 387 | ) 388 | } 389 | 390 | const pos = this._verts[vertIndex] 391 | this._verts.splice(vertIndex, 1) 392 | this._collapseVerts() 393 | this._resetAABox = true 394 | this._geomDirty = true 395 | 396 | this.fire("changed:geom:removevert", { 397 | attr: `verts[${vertIndex}]`, 398 | currVal: pos 399 | }) 400 | 401 | return vertIndex 402 | } 403 | 404 | _rebuildAABox() { 405 | AABox2d.initEmpty(this._aabox) 406 | if (this._verts.length < 3) { 407 | this._convexHull = this._verts.map((val, idx) => idx) 408 | } else { 409 | this._convexHull = simpleHull_2D(this._verts) 410 | } 411 | this._convexHull.forEach(idx => { 412 | aaboxEncapsulatePt( 413 | this._aabox, 414 | this._verts[idx], 415 | idx, 416 | this._extentIndices 417 | ) 418 | }) 419 | 420 | const pivot = Point2d.create(0, 0) 421 | AABox2d.getCenter(pivot, this._aabox) 422 | this.pivot = pivot 423 | } 424 | 425 | _updateAABox() { 426 | if (this._resetAABox) { 427 | this._rebuildAABox() 428 | this._resetAABox = false 429 | } 430 | 431 | if (this._boundsOutOfDate || this._geomDirty) { 432 | AABox2d.initEmpty(this._aabox) 433 | const tmppt = Point2d.create() 434 | const xform = this.globalXform 435 | this._convexHull.forEach(idx => { 436 | AABox2d.encapsulatePt( 437 | this._aabox, 438 | this._aabox, 439 | Point2d.transformMat2d(tmppt, this._verts[idx], xform) 440 | ) 441 | }) 442 | this._boundsOutOfDate = false 443 | 444 | if (this._geomDirty) { 445 | const pivot = Point2d.create() 446 | pivot[0] = 447 | this._verts[this._extentIndices[0]][0] + 448 | 0.5 * 449 | (this._verts[this._extentIndices[2]][0] - 450 | this._verts[this._extentIndices[0]][0]) 451 | pivot[1] = 452 | this._verts[this._extentIndices[1]][1] + 453 | 0.5 * 454 | (this._verts[this._extentIndices[3]][1] - 455 | this._verts[this._extentIndices[1]][1]) 456 | this.pivot = pivot 457 | this._geomDirty = false 458 | } 459 | } 460 | } 461 | 462 | _draw(ctx) { 463 | let rtn = false 464 | if (this._verts.length >= 2) { 465 | ctx.setTransform(1, 0, 0, 1, 0, 0) 466 | const proj_pt = Point2d.create() 467 | Point2d.transformMat2d(proj_pt, this._verts[0], this._fullXform) 468 | ctx.moveTo(proj_pt[0], proj_pt[1]) 469 | for (let i = 1; i < this._verts.length; i += 1) { 470 | Point2d.transformMat2d(proj_pt, this._verts[i], this._fullXform) 471 | ctx.lineTo(proj_pt[0], proj_pt[1]) 472 | } 473 | rtn = true 474 | } 475 | return rtn 476 | } 477 | 478 | toJSON() { 479 | return Object.assign( 480 | { 481 | type: "PolyLine", // NOTE: this much match the name of the class 482 | verts: this.vertsRef.map(vert => [vert[0], vert[1]]) 483 | }, 484 | super.toJSON() 485 | ) 486 | } 487 | } 488 | 489 | PolyLine.aaboxEncapsulatePt = aaboxEncapsulatePt 490 | --------------------------------------------------------------------------------