├── .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 |
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 |
--------------------------------------------------------------------------------