may be provided and follow a convention similar to git trailer format.
84 |
85 | Additional types are not mandated by the Conventional Commits specification, and have no implicit effect in semantic versioning (unless they include a BREAKING CHANGE). A scope may be provided to a commit’s type, to provide additional contextual information and is contained within parenthesis, e.g., feat(parser): add ability to parse arrays.
86 |
87 | ## Contributing
88 |
89 | All PRs are welcome and will be reviewed soon or later. Please make sure to follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification.
90 |
--------------------------------------------------------------------------------
/api.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | OpenLayers Editor
4 |
5 |
6 |
7 |
8 |
31 |
32 |
33 |
34 |
54 |
--------------------------------------------------------------------------------
/commitlint.config.js:
--------------------------------------------------------------------------------
1 | module.exports = { extends: ['@commitlint/config-conventional'] };
2 |
--------------------------------------------------------------------------------
/config/tsconfig-build.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Basic Options */
4 | "target": "es2017" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */,
5 | "module": "es2020" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
6 | // "lib": [], /* Specify library files to be included in the compilation. */
7 | "allowJs": true /* Allow javascript files to be compiled. */,
8 | // "checkJs": true, /* Report errors in .js files. */
9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
10 | "declaration": true /* Generates corresponding '.d.ts' file. */,
11 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
12 | "sourceMap": false /* Generates corresponding '.map' file. */,
13 | // "outFile": "./", /* Concatenate and emit output to single file. */
14 | "outDir": "../build" /* Redirect output structure to the directory. */,
15 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
16 | // "composite": true, /* Enable project compilation */
17 | // "removeComments": true, /* Do not emit comments to output. */
18 | // "noEmit": true, /* Do not emit outputs. */
19 | "importHelpers": false /* Import emit helpers from 'tslib'. */,
20 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
21 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
22 |
23 | /* Strict Type-Checking Options */
24 | "strict": false /* Enable all strict type-checking options. */,
25 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
26 | "strictNullChecks": true /* Enable strict null checks. */,
27 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */
28 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
29 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
30 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
31 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
32 |
33 | /* Additional Checks */
34 | // "noUnusedLocals": true, /* Report errors on unused locals. */
35 | // "noUnusedParameters": true, /* Report errors on unused parameters. */
36 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
37 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
38 |
39 | /* Module Resolution Options */
40 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
41 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
42 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
43 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
44 | // "typeRoots": [], /* List of folders to include type definitions from. */
45 | // "types": [], /* Type declaration files to be included in compilation. */
46 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
47 | "esModuleInterop": false /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
48 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
49 |
50 | /* Source Map Options */
51 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
52 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
53 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
54 | "inlineSources": false /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */,
55 | "skipLibCheck": true
56 | /* Experimental Options */
57 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
58 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
59 | },
60 | "include": ["../src/**/*.js"],
61 | "exclude": []
62 | }
63 |
--------------------------------------------------------------------------------
/cypress.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'cypress';
2 |
3 | export default defineConfig({
4 | projectId: 'f88nv7',
5 | e2e: {
6 | baseUrl: 'http://localhost:8000',
7 | specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}',
8 | supportFile: false,
9 | },
10 | });
11 |
--------------------------------------------------------------------------------
/cypress/e2e/control/cad.spec.js:
--------------------------------------------------------------------------------
1 | const FORCE = { force: true };
2 |
3 | const coordToFixed = (coordArray, decimals) => {
4 | const arr = [
5 | parseFloat(coordArray[0].toFixed(decimals)),
6 | parseFloat(coordArray[1].toFixed(decimals)),
7 | ];
8 | return arr;
9 | };
10 |
11 | describe('CAD control', () => {
12 | beforeEach(() => {
13 | cy.visit('/');
14 |
15 | // Draw point (click on map canvas container at x: 500 and y: 500)
16 | cy.get('[title="Draw Point"]').click();
17 | cy.get('.ol-overlaycontainer').click(500, 500, FORCE);
18 | });
19 |
20 | it.only('should not snap new points when CAD deactivated', () => {
21 | cy.window().then((win) => {
22 | // Draw new point (click on map canvas container at x: 507 and y: 500)
23 | cy.get('.ol-overlaycontainer')
24 | .click(507, 500, FORCE)
25 | .then(() => {
26 | const newPoint = win.editLayer.getSource().getFeatures()[1];
27 | // New point should not have additional snapping distance in coordinate
28 | expect(
29 | JSON.stringify(newPoint.getGeometry().getCoordinates()),
30 | ).to.equal(
31 | JSON.stringify(win.map.getCoordinateFromPixel([507, 500])),
32 | );
33 | });
34 | });
35 | });
36 |
37 | it('should snap new points to CAD point with CAD active', () => {
38 | cy.window().then((win) => {
39 | // Activate CAD control (click on toolbar)
40 | cy.get('.ole-control-cad').click();
41 | // Draw new point (click on map canvas container at x: 507 and y: 500)
42 | cy.get('.ol-overlaycontainer')
43 | .click(507, 500, FORCE)
44 | .then(() => {
45 | const snapDistance = win.cad.properties.snapPointDist;
46 | const newPoint = win.editLayer.getSource().getFeatures()[1];
47 | // New point should have added snapping distance (use toFixed to ignore micro differences)
48 | expect(
49 | JSON.stringify(
50 | coordToFixed(newPoint.getGeometry().getCoordinates(), 5),
51 | ),
52 | ).to.equal(
53 | JSON.stringify(
54 | coordToFixed(
55 | win.map.getCoordinateFromPixel([500 + snapDistance, 500]),
56 | 5,
57 | ),
58 | ),
59 | );
60 | });
61 | });
62 | });
63 | });
64 |
--------------------------------------------------------------------------------
/cypress/e2e/control/difference.spec.js:
--------------------------------------------------------------------------------
1 | const FORCE = { force: true };
2 |
3 | describe('Difference control', () => {
4 | beforeEach(() => {
5 | cy.visit('/');
6 |
7 | // Draw polygon (click on map container, double click to finish drawing)
8 | cy.get('[title="Draw Polygon"]').click();
9 | cy.get('.ol-overlaycontainer').click(500, 200, FORCE);
10 | cy.get('.ol-overlaycontainer').click(600, 400, FORCE);
11 | cy.get('.ol-overlaycontainer').dblclick(400, 400, FORCE);
12 |
13 | // Draw overlapping polygon (click on map container, double click to finish drawing)
14 | cy.get('.ol-overlaycontainer').click(600, 200, FORCE);
15 | cy.get('.ol-overlaycontainer').click(550, 350, FORCE);
16 | cy.get('.ol-overlaycontainer').dblclick(400, 300, FORCE);
17 | });
18 |
19 | it('should subtract overlapping polygons and result in the correct multipolygon', () => {
20 | cy.window().then((win) => {
21 | // Activate union tool (click on toolbar)
22 | cy.get('.ole-control-difference')
23 | .click()
24 | .then(() => {
25 | // Click on map canvas to select polygon for subtraction
26 | cy.get('.ol-overlaycontainer').click(500, 210, FORCE);
27 | })
28 | .then(() => {
29 | cy.wait(1000); // Wait to avoid zoom on map due to load races
30 | // Click on map canvas to select polygon to subtract
31 | cy.get('.ol-overlaycontainer').click(580, 220, FORCE);
32 | cy.wait(1000).then(() => {
33 | const united = win.editLayer.getSource().getFeatures()[0];
34 | // Should result in a multipolygon (thus have two coordinate arrays)
35 | expect(united.getGeometry().getCoordinates().length).to.equal(2);
36 | // First polygon should result in a triangle (3 nodes, 4 coordinates)
37 | expect(united.getGeometry().getCoordinates()[0][0].length).to.equal(
38 | 4,
39 | );
40 | // Second polygon should have 5 nodes (6 coordinates)
41 | expect(united.getGeometry().getCoordinates()[1][0].length).to.equal(
42 | 6,
43 | );
44 | });
45 | });
46 | });
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/cypress/e2e/control/draw.spec.js:
--------------------------------------------------------------------------------
1 | describe('Draw control', () => {
2 | beforeEach(() => {
3 | cy.visit('/');
4 | });
5 |
6 | it('should show draw control for points', () => {
7 | cy.get('.ole-control-draw')
8 | .first()
9 | .should('have.attr', 'title', 'Draw Point');
10 |
11 | cy.get('.ole-control-draw').first().click();
12 | cy.get('.ol-viewport').click('center');
13 | cy.window().then((win) =>
14 | expect(win.editLayer.getSource().getFeatures().length).to.eq(1),
15 | );
16 | });
17 |
18 | it('should show draw control for lines', () => {
19 | cy.get('.ole-control-draw')
20 | .eq(1)
21 | .should('have.attr', 'title', 'Draw LineString');
22 | });
23 |
24 | it('should show draw control for polygons', () => {
25 | cy.get('.ole-control-draw')
26 | .last()
27 | .should('have.attr', 'title', 'Draw Polygon');
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/cypress/e2e/control/intersection.spec.js:
--------------------------------------------------------------------------------
1 | const FORCE = { force: true };
2 |
3 | describe('Intersect control', () => {
4 | beforeEach(() => {
5 | cy.visit('/');
6 |
7 | // Draw polygon (click on map container, double click to finish drawing)
8 | cy.get('[title="Draw Polygon"]').click();
9 | cy.get('.ol-overlaycontainer').click(500, 200, FORCE);
10 | cy.get('.ol-overlaycontainer').click(600, 400, FORCE);
11 | cy.get('.ol-overlaycontainer').dblclick(400, 400, FORCE);
12 |
13 | // Draw overlapping polygon (click on map container, double click to finish drawing)
14 | cy.get('.ol-overlaycontainer').click(600, 200, FORCE);
15 | cy.get('.ol-overlaycontainer').click(550, 350, FORCE);
16 | cy.get('.ol-overlaycontainer').dblclick(400, 300, FORCE);
17 | });
18 |
19 | it('should intersect two overlapping polygons resulting in one with correct nodes', () => {
20 | cy.window().then((win) => {
21 | // Activate union tool (click on toolbar)
22 | cy.get('.ole-control-intersection')
23 | .click()
24 | .then(() => {
25 | // Click on map canvas to select polygon for intersection
26 | cy.get('.ol-overlaycontainer').click(500, 210, FORCE);
27 | })
28 | .then(() => {
29 | cy.wait(1000); // Wait to avoid zoom on map due to load races
30 | // Click on map canvas to select overlapping polygon
31 | cy.get('.ol-overlaycontainer').click(580, 220, FORCE);
32 | cy.wait(1000).then(() => {
33 | // New (united) polygon should have 5 nodes (6 coordinates)
34 | const united = win.editLayer.getSource().getFeatures()[0];
35 | expect(united.getGeometry().getCoordinates()[0].length).to.equal(6);
36 | });
37 | });
38 | });
39 | });
40 | });
41 |
--------------------------------------------------------------------------------
/cypress/e2e/control/modify.spec.js:
--------------------------------------------------------------------------------
1 | const FORCE = { force: true };
2 |
3 | describe('ModifyControl', () => {
4 | beforeEach(() => {
5 | cy.visit('/');
6 |
7 | // Draw polygon (click on map container, double click to finish drawing)
8 | cy.get('[title="Draw Polygon"]').click();
9 | cy.get('.ol-overlaycontainer').click(100, 100, FORCE);
10 | cy.get('.ol-overlaycontainer').click(100, 150, FORCE);
11 | cy.get('.ol-overlaycontainer').click(150, 170, FORCE);
12 | cy.get('.ol-overlaycontainer').dblclick(200, 100, FORCE);
13 |
14 | // Draw line (click on map container, double click to finish drawing)
15 | cy.get('[title="Draw LineString"]').click();
16 | cy.get('.ol-overlaycontainer').click(400, 350, FORCE);
17 | cy.get('.ol-overlaycontainer').click(270, 344, FORCE);
18 | cy.get('.ol-overlaycontainer').dblclick(200, 450, FORCE);
19 | });
20 |
21 | it('should correctly handle node deletion', () => {
22 | cy.window().then((win) => {
23 | // Spy on selectModify.addFeatureLayerAssociation_, called when a feature is selected
24 | const omitFeatureSelectSpy = cy.spy(
25 | win.modify.selectModify,
26 | 'addFeatureLayerAssociation_',
27 | );
28 | let selectedFeaturesArray = [];
29 | // Select Modify Control (click on toolbar)
30 | cy.get('.ole-control-modify').click();
31 | // Select polygon (double click polygon in map canvas container to start modifying)
32 | cy.get('.ol-viewport')
33 | .dblclick(100, 100, FORCE)
34 | .then(() => {
35 | selectedFeaturesArray = win.modify.selectModify
36 | .getFeatures()
37 | .getArray();
38 | // Check if only one feature is selected
39 | expect(selectedFeaturesArray.length).to.equal(1);
40 | // Verify the polygon has 4 nodes (5 coordinates)
41 | expect(
42 | selectedFeaturesArray[0].getGeometry().getCoordinates()[0].length,
43 | ).to.equal(5);
44 | });
45 | // Click & delete a node (click on map canvas at node pixel)
46 | // Click & delete a node (click on map canvas at node pixel)
47 | cy.get('.ol-viewport')
48 | .click(102, 152)
49 | .then(() => {
50 | // singleclick event needs a timeout period.
51 | cy.wait(400).then(() => {
52 | selectedFeaturesArray = win.modify.selectModify
53 | .getFeatures()
54 | .getArray();
55 | // Verify one polygon node was deleted on click (3 nodes, 4 coordinates)
56 | expect(
57 | selectedFeaturesArray[0].getGeometry().getCoordinates()[0].length,
58 | ).to.equal(4);
59 | });
60 | });
61 |
62 | // Click another node (click on map canvas at node pixel)
63 | cy.get('.ol-viewport')
64 | .click(100, 100, FORCE)
65 | .then(() => {
66 | // singleclick event needs a timeout period.
67 | cy.wait(400).then(() => {
68 | // Verify no further node was deleted on click (because polygon minimum number nodes is 3)
69 | expect(
70 | selectedFeaturesArray[0].getGeometry().getCoordinates()[0].length,
71 | ).to.equal(4);
72 | // Check that no features from the overlay are mistakenly selected
73 | const toTest = omitFeatureSelectSpy.withArgs(
74 | omitFeatureSelectSpy.args[0][0],
75 | null,
76 | );
77 | // eslint-disable-next-line no-unused-expressions
78 | expect(toTest).to.not.be.called;
79 | });
80 | });
81 |
82 | // Select line (double click line in map canvas container to start modifying)
83 | cy.get('.ol-viewport')
84 | .dblclick(270, 344, FORCE)
85 | .then(() => {
86 | selectedFeaturesArray = win.modify.selectModify
87 | .getFeatures()
88 | .getArray();
89 | // Check if only one feature is selected
90 | expect(selectedFeaturesArray.length).to.equal(1);
91 | // Verify the line has 3 nodes (3 coordinates)
92 | expect(
93 | selectedFeaturesArray[0].getGeometry().getCoordinates().length,
94 | ).to.equal(3);
95 | });
96 |
97 | // Click & delete a node (click on map canvas at node pixel)
98 | cy.get('.ol-viewport')
99 | .click(270, 344, FORCE)
100 | .then(() => {
101 | // singleclick event needs a timeout period.
102 | cy.wait(400).then(() => {
103 | // Verify one line node was deleted on click (2 nodes, 2 coordinates)
104 | expect(
105 | selectedFeaturesArray[0].getGeometry().getCoordinates().length,
106 | ).to.equal(2);
107 | });
108 | });
109 |
110 | // Click another node (click on map canvas at node pixel)
111 | cy.get('.ol-viewport')
112 | .click(400, 350, FORCE)
113 | .then(() => {
114 | // Verify no further node was deleted on click (because polygon minimum number nodes is 2)
115 | expect(
116 | selectedFeaturesArray[0].getGeometry().getCoordinates().length,
117 | ).to.equal(2);
118 | // Check that no features from the overlay are mistakenly selected
119 | // eslint-disable-next-line no-unused-expressions
120 | expect(
121 | omitFeatureSelectSpy.withArgs(
122 | omitFeatureSelectSpy.args[0][0],
123 | null,
124 | ),
125 | ).to.not.be.called;
126 | });
127 | });
128 | });
129 | });
130 |
--------------------------------------------------------------------------------
/cypress/e2e/control/union.spec.js:
--------------------------------------------------------------------------------
1 | const FORCE = { force: true };
2 |
3 | describe('Union control', () => {
4 | beforeEach(() => {
5 | cy.visit('/');
6 |
7 | // Draw polygon (click on map container, double click to finish drawing)
8 | cy.get('[title="Draw Polygon"]').click();
9 | cy.get('.ol-overlaycontainer').click(500, 200, FORCE);
10 | cy.get('.ol-overlaycontainer').click(600, 400, FORCE);
11 | cy.get('.ol-overlaycontainer').dblclick(400, 400, FORCE);
12 |
13 | // Draw overlapping polygon (click on map container, double click to finish drawing)
14 | cy.get('.ol-overlaycontainer').click(600, 200, FORCE);
15 | cy.get('.ol-overlaycontainer').click(550, 350, FORCE);
16 | cy.get('.ol-overlaycontainer').dblclick(400, 300, FORCE);
17 | });
18 |
19 | it('should unite two overlapping polygons to one polygon with correct nodes', () => {
20 | cy.window().then((win) => {
21 | // Activate union tool (click on toolbar)
22 | cy.get('.ole-control-union')
23 | .click()
24 | .then(() => {
25 | // Click on map canvas to select polygon for unison
26 | cy.get('.ol-overlaycontainer').click(500, 210, FORCE);
27 | })
28 | .then(() => {
29 | cy.wait(1000); // Wait to avoid zoom on map due to load races
30 | // Click on map canvas to select overlapping polygon
31 | cy.get('.ol-overlaycontainer').click(580, 220, FORCE);
32 | cy.wait(1000).then(() => {
33 | // New (united) polygon should have 9 nodes (10 coordinates)
34 | const united = win.editLayer.getSource().getFeatures()[0];
35 | expect(united.getGeometry().getCoordinates()[0].length).to.equal(
36 | 10,
37 | );
38 | });
39 | });
40 | });
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/cypress/e2e/ole.spec.js:
--------------------------------------------------------------------------------
1 | describe('OLE', () => {
2 | beforeEach(() => {
3 | cy.visit('/');
4 | });
5 |
6 | it('should initialize OLE toolbar', () => {
7 | cy.get('#ole-toolbar').should('exist');
8 | });
9 | });
10 |
--------------------------------------------------------------------------------
/img/buffer.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/img/cad.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/img/difference.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/img/draw_line.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/img/draw_point.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/img/draw_polygon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/img/intersection.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/img/modify_geometry.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/img/modify_geometry2.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/img/rotate.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/img/rotate_map.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/img/union.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | OpenLayers Editor
4 |
5 |
6 |
10 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
79 |
214 |
215 |
216 |
--------------------------------------------------------------------------------
/jsdoc_conf.json:
--------------------------------------------------------------------------------
1 | {
2 | "tags": {
3 | "allowUnknownTags": true
4 | },
5 | "source": {
6 | "includePattern": ".+\\.js(doc|x)?$",
7 | "excludePattern": "(^|\\/|\\\\)_"
8 | },
9 | "plugins": ["node_modules/jsdoc-export-default-interop/dist/index"],
10 | "templates": {
11 | "cleverLinks": false,
12 | "monospaceLinks": false,
13 | "default": {
14 | "outputSourceFiles": true
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ole",
3 | "license": "BSD-2-Clause",
4 | "description": "OpenLayers Editor",
5 | "version": "2.4.5",
6 | "main": "build/index.js",
7 | "dependencies": {},
8 | "peerDependencies": {
9 | "jsts": ">=2",
10 | "lodash.throttle": ">=4",
11 | "ol": ">=7"
12 | },
13 | "devDependencies": {
14 | "@commitlint/cli": "19.5.0",
15 | "@commitlint/config-conventional": "19.5.0",
16 | "cypress": "13.14.2",
17 | "esbuild": "0.23.1",
18 | "eslint": "8",
19 | "eslint-config-airbnb-base": "15.0.0",
20 | "eslint-config-prettier": "9.1.0",
21 | "eslint-plugin-cypress": "3.5.0",
22 | "eslint-plugin-import": "2.30.0",
23 | "eslint-plugin-prettier": "5.2.1",
24 | "fixpack": "4.0.0",
25 | "happy-dom": "^15.7.4",
26 | "husky": "9.1.6",
27 | "is-ci": "3.0.1",
28 | "jsdoc": "4.0.3",
29 | "jsdoc-export-default-interop": "0.3.1",
30 | "jsts": "2.11.3",
31 | "lint-staged": "15.2.10",
32 | "lodash.throttle": "4.1.1",
33 | "ol": "^10.1.0",
34 | "prettier": "3.3.3",
35 | "shx": "0.3.4",
36 | "standard-version": "9.5.0",
37 | "start-server-and-test": "2.0.8",
38 | "stylelint": "16.9.0",
39 | "stylelint-config-standard": "36.0.1",
40 | "typescript": "5.6.2",
41 | "vitest": "^2.1.1"
42 | },
43 | "scripts": {
44 | "build": "shx rm -rf build && tsc --project config/tsconfig-build.json && esbuild build/index.js --bundle --global-name=ole --loader:.svg=dataurl --minify --outfile=build/bundle.js",
45 | "cy:open": "cypress open",
46 | "cy:run": "cypress run --browser chrome",
47 | "cy:test": "start-server-and-test start http://127.0.0.1:8000 cy:run",
48 | "doc": "jsdoc -p -r -c jsdoc_conf.json src -d doc README.md && shx cp build/bundle.js index.js",
49 | "fixpack": "fixpack",
50 | "format": "prettier --write 'cypress/integration/*.js' 'src/**/*.js' && eslint 'src/**/*.js' --fix && stylelint 'style/**/*.css' 'src/**/*.css' 'src/**/*.scss' --fix",
51 | "lint": "ESLINT_USE_FLAT_CONFIG=false eslint 'cypress/e2e/**/*.js' 'src/**/*.js' && stylelint 'style/**/*.css' 'src/**/*.css' 'src/**/*.scss'",
52 | "prepare": "is-ci || husky",
53 | "publish:beta": "yarn release -- --prerelease beta --skip.changelog && yarn build && git push origin HEAD && git push --tags && yarn publish --tag beta",
54 | "publish:beta:dryrun": "yarn release -- --prerelease beta --dry-run --skip.changelog",
55 | "publish:public": "yarn release && yarn build && git push origin HEAD && git push --tags && yarn publish",
56 | "publish:public:dryrun": "yarn release --dry-run",
57 | "release": "standard-version",
58 | "start": "esbuild src/index.js --bundle --global-name=ole --loader:.svg=dataurl --minify --outfile=index.js --serve=localhost:8000 --servedir=. --sourcemap --watch=forever",
59 | "test": "vitest",
60 | "up": "yarn upgrade-interactive --latest"
61 | },
62 | "keywords": [
63 | "Editor",
64 | "OpenLayers"
65 | ],
66 | "packageManager": "yarn@1.22.19+sha256.732620bac8b1690d507274f025f3c6cfdc3627a84d9642e38a07452cc00e0f2e",
67 | "repository": "github:geops/openlayers-editor"
68 | }
69 |
--------------------------------------------------------------------------------
/pull_request_template.md:
--------------------------------------------------------------------------------
1 | # How to
2 |
3 |
4 |
5 | # Others
6 |
7 |
8 |
9 | - [ ] It's not a hack or at least an unauthorized hack :).
10 | - [ ] The images added are optimized.
11 | - [ ] Everything in ticket description has been fixed.
12 | - [ ] The author of the MR has made its own review before assigning the reviewer.
13 | - [ ] The title is formatted as a [conventional-commit](https://www.conventionalcommits.org/) message.
14 | - [ ] The title contains `WIP:` if it's necessary.
15 | - [ ] Labels applied. if it's a release? a hotfix?
16 | - [ ] Tests added.
17 |
18 |
19 | IMPORTANT: Squash commits before or on merge to prevent every small commit being written into the change log. The Pull Request title will be written as message for the new commit containing the squashed commits and there fore needs to be in conventional-commit format
20 |
21 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "config:base"
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/src/control/buffer.js:
--------------------------------------------------------------------------------
1 | import OL3Parser from 'jsts/org/locationtech/jts/io/OL3Parser';
2 | import { BufferOp } from 'jsts/org/locationtech/jts/operation/buffer';
3 | import LinearRing from 'ol/geom/LinearRing';
4 | import {
5 | Point,
6 | LineString,
7 | Polygon,
8 | MultiPoint,
9 | MultiLineString,
10 | MultiPolygon,
11 | } from 'ol/geom';
12 | import Select from 'ol/interaction/Select';
13 | import Control from './control';
14 | import bufferSVG from '../../img/buffer.svg';
15 |
16 | /**
17 | * Control for creating buffers.
18 | * @extends {Control}
19 | * @alias ole.BufferControl
20 | */
21 | class BufferControl extends Control {
22 | /**
23 | * @inheritdoc
24 | * @param {Object} [options] Control options.
25 | * @param {number} [options.hitTolerance] Select tolerance in pixels
26 | * (default is 10)
27 | * @param {boolean} [options.multi] Allow selection of multiple geometries
28 | * (default is false).
29 | * @param {ol.style.Style.StyleLike} [options.style] Style used when a feature is selected.
30 | */
31 | constructor(options) {
32 | super({
33 | title: 'Buffer geometry',
34 | className: 'ole-control-buffer',
35 | image: bufferSVG,
36 | buffer: 50,
37 | ...options,
38 | });
39 |
40 | /**
41 | * @type {ol.interaction.Select}
42 | * @private
43 | */
44 | this.selectInteraction = new Select({
45 | layers: this.layerFilter,
46 | hitTolerance:
47 | options.hitTolerance === undefined ? 10 : options.hitTolerance,
48 | multi: typeof options.multi === 'undefined' ? true : options.multi,
49 | style: options.style,
50 | });
51 | }
52 |
53 | /**
54 | * @inheritdoc
55 | */
56 | getDialogTemplate() {
57 | return `
58 |
63 |
64 | `;
65 | }
66 |
67 | /**
68 | * Apply a buffer for seleted features.
69 | * @param {Number} width Buffer width in map units.
70 | */
71 | buffer(width) {
72 | const parser = new OL3Parser();
73 | parser.inject(
74 | Point,
75 | LineString,
76 | LinearRing,
77 | Polygon,
78 | MultiPoint,
79 | MultiLineString,
80 | MultiPolygon,
81 | );
82 |
83 | const features = this.selectInteraction.getFeatures().getArray();
84 | for (let i = 0; i < features.length; i += 1) {
85 | const jstsGeom = parser.read(features[i].getGeometry());
86 | const bo = new BufferOp(jstsGeom);
87 | const buffered = bo.getResultGeometry(width);
88 | features[i].setGeometry(parser.write(buffered));
89 | }
90 | }
91 |
92 | /**
93 | * @inheritdoc
94 | */
95 | activate() {
96 | this.map?.addInteraction(this.selectInteraction);
97 | super.activate();
98 |
99 | document.getElementById('buffer-width')?.addEventListener('change', (e) => {
100 | this.setProperties({ buffer: e.target.value });
101 | });
102 |
103 | document.getElementById('buffer-btn')?.addEventListener('click', () => {
104 | const input = document.getElementById('buffer-width');
105 | const width = parseInt(input.value, 10);
106 |
107 | if (width) {
108 | this.buffer(width);
109 | }
110 | });
111 | }
112 |
113 | /**
114 | * @inheritdoc
115 | */
116 | deactivate() {
117 | this.map?.removeInteraction(this.selectInteraction);
118 | super.deactivate();
119 | }
120 | }
121 |
122 | export default BufferControl;
123 |
--------------------------------------------------------------------------------
/src/control/cad.js:
--------------------------------------------------------------------------------
1 | import { Style, Stroke } from 'ol/style';
2 | import { Point, LineString, Polygon, MultiPoint, Circle } from 'ol/geom';
3 | import Feature from 'ol/Feature';
4 | import Vector from 'ol/layer/Vector';
5 | import VectorSource from 'ol/source/Vector';
6 | import { Pointer, Snap } from 'ol/interaction';
7 | import { OverlayOp } from 'jsts/org/locationtech/jts/operation/overlay';
8 | import { getUid } from 'ol/util';
9 | import Control from './control';
10 | import cadSVG from '../../img/cad.svg';
11 | import { SnapEvent, SnapEventType } from '../event';
12 | import {
13 | parser,
14 | getProjectedPoint,
15 | getEquationOfLine,
16 | getShiftedMultiPoint,
17 | getIntersectedLinesAndPoint,
18 | isSameLines,
19 | defaultSnapStyles,
20 | VH_LINE_KEY,
21 | SNAP_POINT_KEY,
22 | SNAP_FEATURE_TYPE_PROPERTY,
23 | SEGMENT_LINE_KEY,
24 | ORTHO_LINE_KEY,
25 | CUSTOM_LINE_KEY,
26 | } from '../helper';
27 |
28 | /**
29 | * Control with snapping functionality for geometry alignment.
30 | * @extends {Control}
31 | * @alias ole.CadControl
32 | */
33 | class CadControl extends Control {
34 | /**
35 | * @param {Object} [options] Tool options.
36 | * @param {Function} [options.drawCustomSnapLines] Allow to draw more snapping lines using selected coordinates.
37 | * @param {Function} [options.filter] Returns an array containing the features
38 | * to include for CAD (takes the source as a single argument).
39 | * @param {Function} [options.extentFilter] An optional spatial filter for the features to snap with. Returns an ol.Extent which will be used by the source.getFeaturesinExtent method.
40 | * @param {Function} [options.lineFilter] An optional filter for the generated snapping lines
41 | * array (takes the lines and cursor coordinate as arguments and returns the new line array)
42 | * @param {Number} [options.nbClosestFeatures] Number of features to use for snapping (closest first). Default is 5.
43 | * @param {Number} [options.snapTolerance] Snap tolerance in pixel
44 | * for snap lines. Default is 10.
45 | * @param {Boolean} [options.showSnapLines] Whether to show
46 | * snap lines (default is true).
47 | * @param {Boolean} [options.showSnapPoints] Whether to show
48 | * snap points around the closest feature.
49 | * @param {Boolean} [options.showOrthoLines] Whether to show
50 | * snap lines that arae perpendicular to segment (default is true).
51 | * @param {Boolean} [options.showSegmentLines] Whether to show
52 | * snap lines that extends a segment (default is true).
53 | * @param {Boolean} [options.showVerticalAndHorizontalLines] Whether to show vertical
54 | * and horizontal lines for each snappable point (default is true).
55 | * @param {Boolean} [options.snapLinesOrder] Define order of display of snap lines,
56 | * must be an array containing the following values 'ortho', 'segment', 'vh'. Default is ['ortho', 'segment', 'vh', 'custom'].
57 | * @param {Number} [options.snapPointDist] Distance of the
58 | * snap points (default is 30).
59 | * @param {Boolean} [options.useMapUnits] Whether to use map units
60 | * as measurement for point snapping. Default is false (pixel are used).
61 | * @param {ol.VectorSource} [options.source] The vector source to retrieve the snappable features from.
62 | * @param {ol.style.Style.StyleLike} [options.snapStyle] Style used for the snap layer.
63 | * @param {ol.style.Style.StyleLike} [options.linesStyle] Style used for the lines layer.
64 | *
65 | */
66 | constructor(options = {}) {
67 | super({
68 | title: 'CAD control',
69 | className: 'ole-control-cad',
70 | image: cadSVG,
71 | showSnapPoints: true,
72 | showSnapLines: false,
73 | showOrthoLines: true,
74 | showSegmentLines: true,
75 | showVerticalAndHorizontalLines: true,
76 | snapPointDist: 10,
77 | snapLinesOrder: ['ortho', 'segment', 'vh'],
78 | ...options,
79 | });
80 |
81 | /**
82 | * Interaction for handling move events.
83 | * @type {ol.interaction.Pointer}
84 | * @private
85 | */
86 | this.pointerInteraction = new Pointer({
87 | handleMoveEvent: this.onMove.bind(this),
88 | });
89 |
90 | /**
91 | * Layer for drawing snapping geometries.
92 | * @type {ol.layer.Vector}
93 | * @private
94 | */
95 | this.snapLayer = new Vector({
96 | source: new VectorSource(),
97 | style: options.snapStyle || [
98 | defaultSnapStyles[VH_LINE_KEY],
99 | defaultSnapStyles[SNAP_POINT_KEY],
100 | ],
101 | });
102 |
103 | /**
104 | * Layer for colored lines indicating
105 | * intersection point between snapping lines.
106 | * @type {ol.layer.Vector}
107 | * @private
108 | */
109 | this.linesLayer = new Vector({
110 | source: new VectorSource(),
111 | style: options.linesStyle || [
112 | new Style({
113 | stroke: new Stroke({
114 | width: 1,
115 | lineDash: [5, 10],
116 | color: '#FF530D',
117 | }),
118 | }),
119 | ],
120 | });
121 |
122 | /**
123 | * Function to draw more snapping lines.
124 | * @type {Function}
125 | * @private
126 | */
127 | this.drawCustomSnapLines = options.drawCustomSnapLines;
128 |
129 | /**
130 | * Number of features to use for snapping (closest first). Default is 5.
131 | * @type {Number}
132 | * @private
133 | */
134 | this.nbClosestFeatures =
135 | options.nbClosestFeatures === undefined ? 5 : options.nbClosestFeatures;
136 |
137 | /**
138 | * Snap tolerance in pixel.
139 | * @type {Number}
140 | * @private
141 | */
142 | this.snapTolerance =
143 | options.snapTolerance === undefined ? 10 : options.snapTolerance;
144 |
145 | /**
146 | * Filter the features to snap with.
147 | * @type {Function}
148 | * @private
149 | */
150 | this.filter = options.filter || (() => true);
151 |
152 | /**
153 | * Filter the features spatially.
154 | */
155 | this.extentFilter =
156 | options.extentFilter ||
157 | (() => [-Infinity, -Infinity, Infinity, Infinity]);
158 |
159 | /**
160 | * Filter the generated line list
161 | */
162 | this.lineFilter = options.lineFilter;
163 |
164 | /**
165 | * Interaction for snapping
166 | * @type {ol.interaction.Snap}
167 | * @private
168 | */
169 | this.snapInteraction = new Snap({
170 | pixelTolerance: this.snapTolerance,
171 | source: this.snapLayer.getSource(),
172 | });
173 |
174 | this.standalone = false;
175 |
176 | this.handleInteractionAdd = this.handleInteractionAdd.bind(this);
177 | }
178 |
179 | /**
180 | * @inheritdoc
181 | */
182 | getDialogTemplate() {
183 | const distLabel = this.properties.useMapUnits ? 'map units' : 'px';
184 |
185 | return `
186 |
187 |
193 |
194 |
195 |
196 |
202 |
203 |
205 |
206 | `;
207 | }
208 |
209 | handleInteractionAdd(evt) {
210 | const pos = evt.target.getArray().indexOf(this.snapInteraction);
211 |
212 | if (
213 | this.snapInteraction.getActive() &&
214 | pos > -1 &&
215 | pos !== evt.target.getLength() - 1
216 | ) {
217 | this.deactivate(true);
218 | this.activate(true);
219 | }
220 | }
221 |
222 | /**
223 | * @inheritdoc
224 | */
225 | setMap(map) {
226 | if (this.map) {
227 | this.map.getInteractions().un('add', this.handleInteractionAdd);
228 | }
229 |
230 | super.setMap(map);
231 |
232 | // Ensure that the snap interaction is at the last position
233 | // as it must be the first to handle the pointermove event.
234 | if (this.map) {
235 | this.map.getInteractions().on('add', this.handleInteractionAdd);
236 | }
237 | }
238 |
239 | /**
240 | * Handle move event.
241 | * @private
242 | * @param {ol.MapBrowserEvent} evt Move event.
243 | */
244 | onMove(evt) {
245 | const features = this.getClosestFeatures(
246 | evt.coordinate,
247 | this.nbClosestFeatures,
248 | );
249 |
250 | this.linesLayer.getSource().clear();
251 | this.snapLayer.getSource().clear();
252 |
253 | this.pointerInteraction.dispatchEvent(
254 | new SnapEvent(SnapEventType.SNAP, features.length ? features : null, evt),
255 | );
256 |
257 | if (this.properties.showSnapLines) {
258 | this.drawSnapLines(evt.coordinate, features);
259 | }
260 |
261 | if (this.properties.showSnapPoints && features.length) {
262 | this.drawSnapPoints(evt.coordinate, features[0]);
263 | }
264 | }
265 |
266 | /**
267 | * Returns a list of the {num} closest features
268 | * to a given coordinate.
269 | * @private
270 | * @param {ol.Coordinate} coordinate Coordinate.
271 | * @param {Number} nbFeatures Number of features to search.
272 | * @returns {Array.} List of closest features.
273 | */
274 | getClosestFeatures(coordinate, nbFeatures = 1) {
275 | const editFeature = this.editor.getEditFeature();
276 | const drawFeature = this.editor.getDrawFeature();
277 | const currentFeatures = [editFeature, drawFeature].filter((f) => !!f);
278 |
279 | const cacheDist = {};
280 | const dist = (f) => {
281 | const uid = getUid(f);
282 | if (!cacheDist[uid]) {
283 | const cCoord = f.getGeometry().getClosestPoint(coordinate);
284 | const dx = cCoord[0] - coordinate[0];
285 | const dy = cCoord[1] - coordinate[1];
286 | cacheDist[uid] = dx * dx + dy * dy;
287 | }
288 | return cacheDist[uid];
289 | };
290 | const sortByDistance = (a, b) => dist(a) - dist(b);
291 |
292 | let features = this.source
293 | .getFeaturesInExtent(this.extentFilter())
294 | .filter(
295 | (feature) => this.filter(feature) && !currentFeatures.includes(feature),
296 | )
297 | .sort(sortByDistance)
298 | .slice(0, nbFeatures);
299 |
300 | // When using showSnapPoints, return all features except edit/draw features
301 | if (this.properties.showSnapPoints) {
302 | return features;
303 | }
304 |
305 | // When using showSnapLines, return all features but edit/draw features are
306 | // cloned to remove the node at the mouse position.
307 | currentFeatures.filter(this.filter).forEach((feature) => {
308 | const geom = feature.getGeometry();
309 |
310 | if (!(geom instanceof Circle) && !(geom instanceof Point)) {
311 | const snapGeom = getShiftedMultiPoint(geom, coordinate);
312 | const isPolygon = geom instanceof Polygon;
313 | const snapFeature = feature.clone();
314 | snapFeature
315 | .getGeometry()
316 | .setCoordinates(
317 | isPolygon ? [snapGeom.getCoordinates()] : snapGeom.getCoordinates(),
318 | );
319 | features = [snapFeature, ...features];
320 | }
321 | });
322 |
323 | return features;
324 | }
325 |
326 | /**
327 | * Returns an extent array, considers the map rotation.
328 | * @private
329 | * @param {ol.Geometry} geometry An OL geometry.
330 | * @returns {Array.} extent array.
331 | */
332 | getRotatedExtent(geometry, coordinate) {
333 | const coordinates =
334 | geometry instanceof Polygon
335 | ? geometry.getCoordinates()[0]
336 | : geometry.getCoordinates();
337 |
338 | if (!coordinates.length) {
339 | // Polygons initially return a geometry with an empty coordinate array, so we need to catch it
340 | return [coordinate];
341 | }
342 |
343 | // Get the extreme X and Y using pixel values so the rotation is considered
344 | const xMin = coordinates.reduce((finalMin, coord) => {
345 | const pixelCurrent = this.map.getPixelFromCoordinate(coord);
346 | const pixelFinal = this.map.getPixelFromCoordinate(
347 | finalMin || coordinates[0],
348 | );
349 | return pixelCurrent[0] <= pixelFinal[0] ? coord : finalMin;
350 | });
351 | const xMax = coordinates.reduce((finalMax, coord) => {
352 | const pixelCurrent = this.map.getPixelFromCoordinate(coord);
353 | const pixelFinal = this.map.getPixelFromCoordinate(
354 | finalMax || coordinates[0],
355 | );
356 | return pixelCurrent[0] >= pixelFinal[0] ? coord : finalMax;
357 | });
358 | const yMin = coordinates.reduce((finalMin, coord) => {
359 | const pixelCurrent = this.map.getPixelFromCoordinate(coord);
360 | const pixelFinal = this.map.getPixelFromCoordinate(
361 | finalMin || coordinates[0],
362 | );
363 | return pixelCurrent[1] <= pixelFinal[1] ? coord : finalMin;
364 | });
365 | const yMax = coordinates.reduce((finalMax, coord) => {
366 | const pixelCurrent = this.map.getPixelFromCoordinate(coord);
367 | const pixelFinal = this.map.getPixelFromCoordinate(
368 | finalMax || coordinates[0],
369 | );
370 | return pixelCurrent[1] >= pixelFinal[1] ? coord : finalMax;
371 | });
372 |
373 | // Create four infinite lines through the extremes X and Y and rotate them
374 | const minVertLine = new LineString([
375 | [xMin[0], -20037508.342789],
376 | [xMin[0], 20037508.342789],
377 | ]);
378 | minVertLine.rotate(this.map.getView().getRotation(), xMin);
379 | const maxVertLine = new LineString([
380 | [xMax[0], -20037508.342789],
381 | [xMax[0], 20037508.342789],
382 | ]);
383 | maxVertLine.rotate(this.map.getView().getRotation(), xMax);
384 | const minHoriLine = new LineString([
385 | [-20037508.342789, yMin[1]],
386 | [20037508.342789, yMin[1]],
387 | ]);
388 | minHoriLine.rotate(this.map.getView().getRotation(), yMin);
389 | const maxHoriLine = new LineString([
390 | [-20037508.342789, yMax[1]],
391 | [20037508.342789, yMax[1]],
392 | ]);
393 | maxHoriLine.rotate(this.map.getView().getRotation(), yMax);
394 |
395 | // Use intersection points of the four lines to get the extent
396 | const intersectTopLeft = OverlayOp.intersection(
397 | parser.read(minVertLine),
398 | parser.read(minHoriLine),
399 | );
400 | const intersectBottomLeft = OverlayOp.intersection(
401 | parser.read(minVertLine),
402 | parser.read(maxHoriLine),
403 | );
404 | const intersectTopRight = OverlayOp.intersection(
405 | parser.read(maxVertLine),
406 | parser.read(minHoriLine),
407 | );
408 | const intersectBottomRight = OverlayOp.intersection(
409 | parser.read(maxVertLine),
410 | parser.read(maxHoriLine),
411 | );
412 |
413 | return [
414 | [intersectTopLeft.getCoordinate().x, intersectTopLeft.getCoordinate().y],
415 | [
416 | intersectBottomLeft.getCoordinate().x,
417 | intersectBottomLeft.getCoordinate().y,
418 | ],
419 | [
420 | intersectTopRight.getCoordinate().x,
421 | intersectTopRight.getCoordinate().y,
422 | ],
423 | [
424 | intersectBottomRight.getCoordinate().x,
425 | intersectBottomRight.getCoordinate().y,
426 | ],
427 | ];
428 | }
429 |
430 | // Calculate lines that are vertical or horizontal to a coordinate.
431 | getVerticalAndHorizontalLines(coordinate, snapCoords) {
432 | // Draw snaplines when cursor vertically or horizontally aligns with a snap feature.
433 | // We draw only on vertical and one horizontal line to avoid crowded lines when polygons or lines have a lot of coordinates.
434 | const halfTol = this.snapTolerance / 2;
435 | const doubleTol = this.snapTolerance * 2;
436 | const mousePx = this.map.getPixelFromCoordinate(coordinate);
437 | const [mouseX, mouseY] = mousePx;
438 | let vLine;
439 | let hLine;
440 | let closerDistanceWithVLine = Infinity;
441 | let closerDistanceWithHLine = Infinity;
442 | for (let i = 0; i < snapCoords.length; i += 1) {
443 | const snapCoord = snapCoords[i];
444 | const snapPx = this.map.getPixelFromCoordinate(snapCoords[i]);
445 | const [snapX, snapY] = snapPx;
446 | const drawVLine = mouseX > snapX - halfTol && mouseX < snapX + halfTol;
447 | const drawHLine = mouseY > snapY - halfTol && mouseY < snapY + halfTol;
448 |
449 | const distanceWithVLine = Math.abs(mouseX - snapX);
450 | const distanceWithHLine = Math.abs(mouseY - snapY);
451 |
452 | if (
453 | (drawVLine && distanceWithVLine > closerDistanceWithVLine) ||
454 | (drawHLine && distanceWithHLine > closerDistanceWithHLine)
455 | ) {
456 | // eslint-disable-next-line no-continue
457 | continue;
458 | }
459 |
460 | let newPt;
461 |
462 | if (drawVLine) {
463 | closerDistanceWithVLine = distanceWithVLine;
464 | const newY = mouseY + (mouseY < snapY ? -doubleTol : doubleTol);
465 | newPt = this.map.getCoordinateFromPixel([snapX, newY]);
466 | } else if (drawHLine) {
467 | closerDistanceWithHLine = distanceWithHLine;
468 | const newX = mouseX + (mouseX < snapX ? -doubleTol : doubleTol);
469 | newPt = this.map.getCoordinateFromPixel([newX, snapY]);
470 | }
471 |
472 | if (newPt) {
473 | const lineCoords = [newPt, snapCoord];
474 | const geom = new LineString(lineCoords);
475 | const feature = new Feature(geom);
476 |
477 | feature.set(SNAP_FEATURE_TYPE_PROPERTY, VH_LINE_KEY);
478 |
479 | if (drawVLine) {
480 | vLine = feature;
481 | }
482 |
483 | if (drawHLine) {
484 | hLine = feature;
485 | }
486 | }
487 | }
488 |
489 | const lines = [];
490 |
491 | if (hLine) {
492 | lines.push(hLine);
493 | }
494 |
495 | if (vLine && vLine !== hLine) {
496 | lines.push(vLine);
497 | }
498 |
499 | return lines;
500 | }
501 |
502 | /**
503 | * For each segment, we calculate lines that extends it.
504 | */
505 | getSegmentLines(coordinate, snapCoords, snapCoordsBefore) {
506 | const mousePx = this.map.getPixelFromCoordinate(coordinate);
507 | const doubleTol = this.snapTolerance * 2;
508 | const [mouseX, mouseY] = mousePx;
509 | const lines = [];
510 |
511 | for (let i = 0; i < snapCoords.length; i += 1) {
512 | if (!snapCoordsBefore[i]) {
513 | // eslint-disable-next-line no-continue
514 | continue;
515 | }
516 | const snapCoordBefore = snapCoordsBefore[i];
517 | const snapCoord = snapCoords[i];
518 | const snapPxBefore = this.map.getPixelFromCoordinate(snapCoordBefore);
519 | const snapPx = this.map.getPixelFromCoordinate(snapCoord);
520 |
521 | const [snapX] = snapPx;
522 |
523 | // Calculate projected point
524 | const projMousePx = getProjectedPoint(mousePx, snapPxBefore, snapPx);
525 | const [projMouseX, projMouseY] = projMousePx;
526 | const distance = Math.sqrt(
527 | (projMouseX - mouseX) ** 2 + (projMouseY - mouseY) ** 2,
528 | );
529 | let newPt;
530 |
531 | if (distance <= this.snapTolerance) {
532 | // lineFunc is undefined when it's a vertical line
533 | const lineFunc = getEquationOfLine(snapPxBefore, snapPx);
534 | const newX = projMouseX + (projMouseX < snapX ? -doubleTol : doubleTol);
535 | if (lineFunc) {
536 | newPt = this.map.getCoordinateFromPixel([
537 | newX,
538 | lineFunc ? lineFunc(newX) : projMouseY,
539 | ]);
540 | }
541 | }
542 |
543 | if (newPt) {
544 | const lineCoords = [snapCoordBefore, snapCoord, newPt];
545 | const geom = new LineString(lineCoords);
546 | const feature = new Feature(geom);
547 | feature.set(SNAP_FEATURE_TYPE_PROPERTY, SEGMENT_LINE_KEY);
548 | lines.push(feature);
549 | }
550 | }
551 | return lines;
552 | }
553 |
554 | /**
555 | * For each segment, we calculate lines that are perpendicular.
556 | */
557 | getOrthoLines(coordinate, snapCoords, snapCoordsBefore) {
558 | const mousePx = this.map.getPixelFromCoordinate(coordinate);
559 | const doubleTol = this.snapTolerance * 2;
560 | const [mouseX, mouseY] = mousePx;
561 | const lines = [];
562 |
563 | for (let i = 0; i < snapCoords.length; i += 1) {
564 | if (!snapCoordsBefore[i]) {
565 | // eslint-disable-next-line no-continue
566 | continue;
567 | }
568 | const snapCoordBefore = snapCoordsBefore[i];
569 | const snapCoord = snapCoords[i];
570 | const snapPxBefore = this.map.getPixelFromCoordinate(snapCoordBefore);
571 | const snapPx = this.map.getPixelFromCoordinate(snapCoord);
572 |
573 | const orthoLine1 = new LineString([snapPxBefore, snapPx]);
574 | orthoLine1.rotate((90 * Math.PI) / 180, snapPxBefore);
575 |
576 | const orthoLine2 = new LineString([snapPx, snapPxBefore]);
577 | orthoLine2.rotate((90 * Math.PI) / 180, snapPx);
578 |
579 | [orthoLine1, orthoLine2].forEach((line) => {
580 | const [anchorPx, last] = line.getCoordinates();
581 | const projMousePx = getProjectedPoint(mousePx, anchorPx, last);
582 | const [projMouseX, projMouseY] = projMousePx;
583 | const distance = Math.sqrt(
584 | (projMouseX - mouseX) ** 2 + (projMouseY - mouseY) ** 2,
585 | );
586 |
587 | let newPt;
588 | if (distance <= this.snapTolerance) {
589 | // lineFunc is undefined when it's a vertical line
590 | const lineFunc = getEquationOfLine(anchorPx, projMousePx);
591 | const newX =
592 | projMouseX + (projMouseX < anchorPx[0] ? -doubleTol : doubleTol);
593 | if (lineFunc) {
594 | newPt = this.map.getCoordinateFromPixel([
595 | newX,
596 | lineFunc ? lineFunc(newX) : projMouseY,
597 | ]);
598 | }
599 | }
600 |
601 | if (newPt) {
602 | const coords = [this.map.getCoordinateFromPixel(anchorPx), newPt];
603 | const geom = new LineString(coords);
604 | const feature = new Feature(geom);
605 | feature.set(SNAP_FEATURE_TYPE_PROPERTY, ORTHO_LINE_KEY);
606 | lines.push(feature);
607 | }
608 | });
609 | }
610 | return lines;
611 | }
612 |
613 | /**
614 | * Draws snap lines by building the extent for
615 | * a pair of features.
616 | * @private
617 | * @param {ol.Coordinate} coordinate Mouse pointer coordinate.
618 | * @param {Array.} features List of features.
619 | */
620 | drawSnapLines(coordinate, features) {
621 | // First get all snap points: neighbouring feature vertices and extent corners
622 | const snapCoordsBefore = []; // store the direct before point in the coordinate array
623 | const snapCoords = [];
624 | const snapCoordsAfter = []; // store the direct next point in the coordinate array
625 |
626 | for (let i = 0; i < features.length; i += 1) {
627 | const geom = features[i].getGeometry();
628 | let featureCoord = geom.getCoordinates();
629 |
630 | if (!featureCoord && geom instanceof Circle) {
631 | featureCoord = geom.getCenter();
632 | }
633 |
634 | // Polygons initially return a geometry with an empty coordinate array, so we need to catch it
635 | if (featureCoord?.length) {
636 | if (geom instanceof Point || geom instanceof Circle) {
637 | snapCoordsBefore.push();
638 | snapCoords.push(featureCoord);
639 | snapCoordsAfter.push();
640 | } else {
641 | // Add feature vertices
642 | // eslint-disable-next-line no-lonely-if
643 | if (geom instanceof LineString) {
644 | for (let j = 0; j < featureCoord.length; j += 1) {
645 | snapCoordsBefore.push(featureCoord[j - 1]);
646 | snapCoords.push(featureCoord[j]);
647 | snapCoordsAfter.push(featureCoord[j + 1]);
648 | }
649 | } else if (geom instanceof Polygon) {
650 | for (let j = 0; j < featureCoord[0].length; j += 1) {
651 | snapCoordsBefore.push(featureCoord[0][j - 1]);
652 | snapCoords.push(featureCoord[0][j]);
653 | snapCoordsAfter.push(featureCoord[0][j + 1]);
654 | }
655 | }
656 |
657 | // Add extent vertices
658 | // const coords = this.getRotatedExtent(geom, coordinate);
659 | // for (let j = 0; j < coords.length; j += 1) {
660 | // snapCoordsBefore.push();
661 | // snapCoords.push(coords[j]);
662 | // snapCoordsNext.push();
663 | // }
664 | }
665 | }
666 | }
667 |
668 | const {
669 | showVerticalAndHorizontalLines,
670 | showOrthoLines,
671 | showSegmentLines,
672 | snapLinesOrder,
673 | } = this.properties;
674 |
675 | let lines = [];
676 | const helpLinesOrdered = [];
677 | const helpLines = {
678 | [ORTHO_LINE_KEY]: [],
679 | [SEGMENT_LINE_KEY]: [],
680 | [VH_LINE_KEY]: [],
681 | [CUSTOM_LINE_KEY]: [],
682 | };
683 |
684 | if (showOrthoLines) {
685 | helpLines[ORTHO_LINE_KEY] =
686 | this.getOrthoLines(coordinate, snapCoords, snapCoordsBefore) || [];
687 | }
688 |
689 | if (showSegmentLines) {
690 | helpLines[SEGMENT_LINE_KEY] =
691 | this.getSegmentLines(coordinate, snapCoords, snapCoordsBefore) || [];
692 | }
693 |
694 | if (showVerticalAndHorizontalLines) {
695 | helpLines[VH_LINE_KEY] =
696 | this.getVerticalAndHorizontalLines(coordinate, snapCoords) || [];
697 | }
698 |
699 | // Add custom lines
700 | if (this.drawCustomSnapLines) {
701 | helpLines[CUSTOM_LINE_KEY] =
702 | this.drawCustomSnapLines(
703 | coordinate,
704 | snapCoords,
705 | snapCoordsBefore,
706 | snapCoordsAfter,
707 | ) || [];
708 | }
709 |
710 | // Add help lines in a defined order.
711 | snapLinesOrder.forEach((lineType) => {
712 | helpLinesOrdered.push(...(helpLines[lineType] || []));
713 | });
714 |
715 | // Remove duplicated lines, comparing their equation using pixels.
716 | helpLinesOrdered.forEach((lineA) => {
717 | if (
718 | !lines.length ||
719 | !lines.find((lineB) => isSameLines(lineA, lineB, this.map))
720 | ) {
721 | lines.push(lineA);
722 | }
723 | });
724 |
725 | if (this.lineFilter) {
726 | lines = this.lineFilter(lines, coordinate);
727 | }
728 |
729 | // We snap on intersections of lines (distance < this.snapTolerance) or on all the help lines.
730 | const intersectFeatures = getIntersectedLinesAndPoint(
731 | coordinate,
732 | lines,
733 | this.map,
734 | this.snapTolerance,
735 | );
736 |
737 | if (intersectFeatures?.length) {
738 | intersectFeatures.forEach((feature) => {
739 | if (feature.getGeometry().getType() === 'Point') {
740 | this.snapLayer.getSource().addFeature(feature);
741 | } else {
742 | this.linesLayer.getSource().addFeature(feature);
743 | }
744 | });
745 | } else {
746 | this.snapLayer.getSource().addFeatures(lines);
747 | }
748 | }
749 |
750 | /**
751 | * Adds snap points to the snapping layer.
752 | * @private
753 | * @param {ol.Coordinate} coordinate cursor coordinate.
754 | * @param {ol.eaturee} feature Feature to draw the snap points for.
755 | */
756 | drawSnapPoints(coordinate, feature) {
757 | const featCoord = feature.getGeometry().getClosestPoint(coordinate);
758 |
759 | const px = this.map.getPixelFromCoordinate(featCoord);
760 | let snapCoords = [];
761 |
762 | if (this.properties.useMapUnits) {
763 | snapCoords = [
764 | [featCoord[0] - this.properties.snapPointDist, featCoord[1]],
765 | [featCoord[0] + this.properties.snapPointDist, featCoord[1]],
766 | [featCoord[0], featCoord[1] - this.properties.snapPointDist],
767 | [featCoord[0], featCoord[1] + this.properties.snapPointDist],
768 | ];
769 | } else {
770 | const snapPx = [
771 | [px[0] - this.properties.snapPointDist, px[1]],
772 | [px[0] + this.properties.snapPointDist, px[1]],
773 | [px[0], px[1] - this.properties.snapPointDist],
774 | [px[0], px[1] + this.properties.snapPointDist],
775 | ];
776 |
777 | for (let j = 0; j < snapPx.length; j += 1) {
778 | snapCoords.push(this.map.getCoordinateFromPixel(snapPx[j]));
779 | }
780 | }
781 |
782 | const snapGeom = new MultiPoint(snapCoords);
783 | this.snapLayer.getSource().addFeature(new Feature(snapGeom));
784 | }
785 |
786 | /**
787 | * @inheritdoc
788 | */
789 | activate(silent) {
790 | super.activate(silent);
791 | this.snapLayer.setMap(this.map);
792 | this.linesLayer.setMap(this.map);
793 | this.map?.addInteraction(this.pointerInteraction);
794 | this.map?.addInteraction(this.snapInteraction);
795 |
796 | document.getElementById('aux-cb')?.addEventListener('change', (evt) => {
797 | this.setProperties({
798 | showSnapLines: evt.target.checked,
799 | showSnapPoints: !evt.target.checked,
800 | });
801 | });
802 |
803 | document.getElementById('dist-cb')?.addEventListener('change', (evt) => {
804 | this.setProperties({
805 | showSnapPoints: evt.target.checked,
806 | showSnapLines: !evt.target.checked,
807 | });
808 | });
809 |
810 | document.getElementById('width-input')?.addEventListener('keyup', (evt) => {
811 | const snapPointDist = parseFloat(evt.target.value);
812 | if (!Number.isNaN(snapPointDist)) {
813 | this.setProperties({ snapPointDist });
814 | }
815 | });
816 | }
817 |
818 | /**
819 | * @inheritdoc
820 | */
821 | deactivate(silent) {
822 | super.deactivate(silent);
823 | this.snapLayer.setMap(null);
824 | this.linesLayer.setMap(null);
825 | this.map?.removeInteraction(this.pointerInteraction);
826 | this.map?.removeInteraction(this.snapInteraction);
827 | }
828 | }
829 |
830 | export default CadControl;
831 |
--------------------------------------------------------------------------------
/src/control/control.js:
--------------------------------------------------------------------------------
1 | import OLControl from 'ol/control/Control';
2 | import VectorSource from 'ol/source/Vector';
3 | /**
4 | * OLE control base class.
5 | * @extends ol.control.Control
6 | * @alias ole.Control
7 | */
8 | class Control extends OLControl {
9 | /**
10 | * @inheritdoc
11 | * @param {Object} options Control options.
12 | * @param {HTMLElement} options.element Element which to substitute button. Set to null if you don't want to display an html element.
13 | * @param {string} options.className Name of the control's HTML class.
14 | * @param {string} options.title Title of the control toolbar button.
15 | * @param {Image} options.image Control toolbar image.
16 | * @param {HTMLElement} [options.dialogTarget] Specify a target if you want
17 | * the dialog div used by the control to be rendered outside of the map's viewport. Set tio null if you don't want to display the dialog of a control.
18 | * @param {ol.source.Vector} [options.source] Vector source holding
19 | * edit features. If undefined, options.features must be passed.
20 | * @param {ol.Collection} [options.features] Collection of
21 | * edit features. If undefined, options.source must be set.
22 | * @param {function} [options.layerFilter] Filter editable layer.
23 | */
24 | constructor(options) {
25 | let button = null;
26 | if (options.element !== null && !options.element) {
27 | button = document.createElement('button');
28 | button.className = `ole-control ${options.className}`;
29 | }
30 |
31 | super({
32 | element:
33 | options.element === null
34 | ? document.createElement('div') // An element must be define otherwise ol complains, when we add control
35 | : options.element || button,
36 | });
37 |
38 | /**
39 | * Specify a target if you want the dialog div used by the
40 | * control to be rendered outside of the map's viewport.
41 | * @type {HTMLElement}
42 | * @private
43 | */
44 | this.dialogTarget = options.dialogTarget;
45 |
46 | /**
47 | * Control properties.
48 | * @type {object}
49 | * @private
50 | */
51 | this.properties = { ...options };
52 |
53 | /**
54 | * Html class name of the control button.
55 | * @type {string}
56 | * @private
57 | */
58 | this.className = options.className;
59 |
60 | /**
61 | * Control title.
62 | * @type {string}
63 | * @private
64 | */
65 | this.title = options.title;
66 |
67 | if (button) {
68 | const img = document.createElement('img');
69 | img.src = options.image;
70 |
71 | button.appendChild(img);
72 | button.title = this.title;
73 |
74 | button.addEventListener('click', this.onClick.bind(this));
75 | }
76 |
77 | /**
78 | * Source with edit features.
79 | * @type {ol.source.Vector}
80 | * @private
81 | */
82 | this.source =
83 | options.source ||
84 | new VectorSource({
85 | features: options.features,
86 | });
87 |
88 | /**
89 | * Filter editable layer. Used by select interactions instead of
90 | * the old source property.
91 | * @type {function}
92 | * @private
93 | */
94 | this.layerFilter =
95 | options.layerFilter ||
96 | ((layer) => !this.source || (layer && layer.getSource() === this.source));
97 |
98 | /**
99 | * ole.Editor instance.
100 | * @type {ole.Editor}
101 | * @private
102 | */
103 | this.editor = null;
104 |
105 | /**
106 | * @type {Boolean}
107 | * @private
108 | */
109 | this.standalone = true;
110 | }
111 |
112 | /**
113 | * Returns the control's element.
114 | * @returns {Element} the control element.
115 | */
116 | getElement() {
117 | return this.element;
118 | }
119 |
120 | /**
121 | * Click handler for the control element.
122 | * @private
123 | */
124 | onClick() {
125 | if (this.active) {
126 | this.deactivate();
127 | } else {
128 | this.activate();
129 | }
130 | }
131 |
132 | /**
133 | * Sets the map of the control.
134 | * @protected
135 | * @param {ol.Map} map The map object.
136 | */
137 | setMap(map) {
138 | this.map = map;
139 | super.setMap(this.map);
140 | }
141 |
142 | /**
143 | * Introduce the control to it's editor.
144 | * @param {ole.Editor} editor OLE Editor.
145 | * @protected
146 | */
147 | setEditor(editor) {
148 | this.editor = editor;
149 | }
150 |
151 | /**
152 | * Activate the control
153 | */
154 | activate(silent) {
155 | this.active = true;
156 | if (this.element) {
157 | this.element.className += ' active';
158 | }
159 |
160 | if (!silent) {
161 | this.dispatchEvent({
162 | type: 'change:active',
163 | target: this,
164 | detail: { control: this },
165 | });
166 | }
167 |
168 | this.openDialog();
169 | }
170 |
171 | /**
172 | * Dectivate the control
173 | * @param {boolean} [silent] Do not trigger an event.
174 | */
175 | deactivate(silent) {
176 | this.active = false;
177 | if (this.element) {
178 | this.element.classList.remove('active');
179 | }
180 |
181 | if (!silent) {
182 | this.dispatchEvent({
183 | type: 'change:active',
184 | target: this,
185 | detail: { control: this },
186 | });
187 | }
188 |
189 | this.closeDialog();
190 | }
191 |
192 | /**
193 | * Returns the active state of the control.
194 | * @returns {Boolean} Active state.
195 | */
196 | getActive() {
197 | return this.active;
198 | }
199 |
200 | /**
201 | * Open the control's dialog (if defined).
202 | */
203 | openDialog() {
204 | this.closeDialog();
205 | if (this.dialogTarget !== null && this.getDialogTemplate) {
206 | this.dialogDiv = document.createElement('div');
207 |
208 | this.dialogDiv.innerHTML = `
209 |
210 | ${this.getDialogTemplate()}
211 |
212 | `;
213 | (this.dialogTarget || this.map.getTargetElement()).appendChild(
214 | this.dialogDiv,
215 | );
216 | }
217 | }
218 |
219 | /**
220 | * Closes the control dialog.
221 | * @private
222 | */
223 | closeDialog() {
224 | if (this.dialogDiv) {
225 | (this.dialogTarget || this.map.getTargetElement()).removeChild(
226 | this.dialogDiv,
227 | );
228 | this.dialogDiv = null;
229 | }
230 | }
231 |
232 | /**
233 | * Set properties.
234 | * @param {object} properties New control properties.
235 | * @param {boolean} [silent] If true, no propertychange event is triggered.
236 | */
237 | setProperties(properties, silent) {
238 | this.properties = { ...this.properties, ...properties };
239 |
240 | if (!silent) {
241 | this.dispatchEvent({
242 | type: 'propertychange',
243 | target: this,
244 | detail: { properties: this.properties, control: this },
245 | });
246 | }
247 | }
248 |
249 | /**
250 | * Return properties.
251 | * @returns {object} Copy of control properties.
252 | */
253 | getProperties() {
254 | return { ...this.properties };
255 | }
256 | }
257 |
258 | export default Control;
259 |
--------------------------------------------------------------------------------
/src/control/difference.js:
--------------------------------------------------------------------------------
1 | import OL3Parser from 'jsts/org/locationtech/jts/io/OL3Parser';
2 | import { OverlayOp } from 'jsts/org/locationtech/jts/operation/overlay';
3 | import LinearRing from 'ol/geom/LinearRing';
4 | import {
5 | Point,
6 | LineString,
7 | Polygon,
8 | MultiPoint,
9 | MultiLineString,
10 | MultiPolygon,
11 | } from 'ol/geom';
12 | import TopologyControl from './topology';
13 | import diffSVG from '../../img/difference.svg';
14 |
15 | /**
16 | * Control for creating a difference of geometries.
17 | * @extends {Control}
18 | * @alias ole.Difference
19 | */
20 | class Difference extends TopologyControl {
21 | /**
22 | * @inheritdoc
23 | * @param {Object} [options] Control options.
24 | * @param {number} [options.hitTolerance] Select tolerance in pixels
25 | * (default is 10)
26 | */
27 | constructor(options) {
28 | super({
29 | title: 'Difference',
30 | className: 'ole-control-difference',
31 | image: diffSVG,
32 | ...options,
33 | });
34 | }
35 |
36 | /**
37 | * Apply a difference operation for given features.
38 | * @param {Array.} features Features.
39 | */
40 | applyTopologyOperation(features) {
41 | super.applyTopologyOperation(features);
42 |
43 | if (features.length < 2) {
44 | return;
45 | }
46 |
47 | const parser = new OL3Parser();
48 | parser.inject(
49 | Point,
50 | LineString,
51 | LinearRing,
52 | Polygon,
53 | MultiPoint,
54 | MultiLineString,
55 | MultiPolygon,
56 | );
57 |
58 | for (let i = 1; i < features.length; i += 1) {
59 | const geom = parser.read(features[0].getGeometry());
60 | const otherGeom = parser.read(features[i].getGeometry());
61 | const diffGeom = OverlayOp.difference(geom, otherGeom);
62 | features[0].setGeometry(parser.write(diffGeom));
63 | features[i].setGeometry(null);
64 | }
65 | }
66 | }
67 |
68 | export default Difference;
69 |
--------------------------------------------------------------------------------
/src/control/draw.js:
--------------------------------------------------------------------------------
1 | import { Draw } from 'ol/interaction';
2 | import Control from './control';
3 | import drawPointSVG from '../../img/draw_point.svg';
4 | import drawPolygonSVG from '../../img/draw_polygon.svg';
5 | import drawLineSVG from '../../img/draw_line.svg';
6 |
7 | /**
8 | * Control for drawing features.
9 | * @extends {Control}
10 | * @alias ole.DrawControl
11 | */
12 | class DrawControl extends Control {
13 | /**
14 | * @param {Object} [options] Tool options.
15 | * @param {string} [options.type] Geometry type ('Point', 'LineString', 'Polygon',
16 | * 'MultiPoint', 'MultiLineString', 'MultiPolygon' or 'Circle').
17 | * Default is 'Point'.
18 | * @param {Object} [options.drawInteractionOptions] Options for the Draw interaction (ol/interaction/Draw).
19 | * @param {ol.style.Style.StyleLike} [options.style] Style used for the draw interaction.
20 | */
21 | constructor(options) {
22 | let image = null;
23 |
24 | switch (options?.type) {
25 | case 'Polygon':
26 | image = drawPolygonSVG;
27 | break;
28 | case 'LineString':
29 | image = drawLineSVG;
30 | break;
31 | default:
32 | image = drawPointSVG;
33 | }
34 |
35 | super({
36 | title: `Draw ${options?.type || 'Point'}`,
37 | className: 'ole-control-draw',
38 | image,
39 | ...(options || {}),
40 | });
41 |
42 | /**
43 | * @type {ol.interaction.Draw}
44 | */
45 | this.drawInteraction = new Draw({
46 | type: options?.type || 'Point',
47 | features: options?.features,
48 | source: options?.source,
49 | style: options?.style,
50 | stopClick: true,
51 | ...(options?.drawInteractionOptions || {}),
52 | });
53 |
54 | this.drawInteraction.on('drawstart', (evt) => {
55 | this.editor.setDrawFeature(evt.feature);
56 | });
57 |
58 | this.drawInteraction.on('drawend', () => {
59 | this.editor.setDrawFeature();
60 | });
61 | }
62 |
63 | /**
64 | * @inheritdoc
65 | */
66 | activate() {
67 | this.map?.addInteraction(this.drawInteraction);
68 | super.activate();
69 | }
70 |
71 | /**
72 | * @inheritdoc
73 | */
74 | deactivate(silent) {
75 | this.map?.removeInteraction(this.drawInteraction);
76 | super.deactivate(silent);
77 | }
78 | }
79 |
80 | export default DrawControl;
81 |
--------------------------------------------------------------------------------
/src/control/index.js:
--------------------------------------------------------------------------------
1 | export { default as Control } from './control';
2 | export { default as CAD } from './cad';
3 | export { default as Rotate } from './rotate';
4 | export { default as Draw } from './draw';
5 | export { default as Modify } from './modify';
6 | export { default as Buffer } from './buffer';
7 | export { default as Union } from './union';
8 | export { default as Intersection } from './intersection';
9 | export { default as Difference } from './difference';
10 | export { default as Toolbar } from './toolbar';
11 |
--------------------------------------------------------------------------------
/src/control/intersection.js:
--------------------------------------------------------------------------------
1 | import OL3Parser from 'jsts/org/locationtech/jts/io/OL3Parser';
2 | import { OverlayOp } from 'jsts/org/locationtech/jts/operation/overlay';
3 | import LinearRing from 'ol/geom/LinearRing';
4 | import {
5 | Point,
6 | LineString,
7 | Polygon,
8 | MultiPoint,
9 | MultiLineString,
10 | MultiPolygon,
11 | } from 'ol/geom';
12 | import TopologyControl from './topology';
13 | import intersectionSVG from '../../img/intersection.svg';
14 |
15 | /**
16 | * Control for intersection geometries.
17 | * @extends {Control}
18 | * @alias ole.Intersection
19 | */
20 | class Intersection extends TopologyControl {
21 | /**
22 | * @inheritdoc
23 | * @param {Object} [options] Control options.
24 | * @param {number} [options.hitTolerance] Select tolerance in pixels
25 | * (default is 10)
26 | */
27 | constructor(options) {
28 | super({
29 | title: 'Intersection',
30 | className: 'ole-control-intersection',
31 | image: intersectionSVG,
32 | ...options,
33 | });
34 | }
35 |
36 | /**
37 | * Intersect given features.
38 | * @param {Array.} features Features to inersect.
39 | */
40 | applyTopologyOperation(features) {
41 | super.applyTopologyOperation(features);
42 |
43 | if (features.length < 2) {
44 | return;
45 | }
46 |
47 | const parser = new OL3Parser();
48 | parser.inject(
49 | Point,
50 | LineString,
51 | LinearRing,
52 | Polygon,
53 | MultiPoint,
54 | MultiLineString,
55 | MultiPolygon,
56 | );
57 |
58 | for (let i = 1; i < features.length; i += 1) {
59 | const geom = parser.read(features[0].getGeometry());
60 | const otherGeom = parser.read(features[i].getGeometry());
61 | const intersectGeom = OverlayOp.intersection(geom, otherGeom);
62 | features[0].setGeometry(parser.write(intersectGeom));
63 | features[i].setGeometry(null);
64 | }
65 | }
66 | }
67 |
68 | export default Intersection;
69 |
--------------------------------------------------------------------------------
/src/control/modify.js:
--------------------------------------------------------------------------------
1 | import { Modify, Interaction } from 'ol/interaction';
2 | import { singleClick } from 'ol/events/condition';
3 | // eslint-disable-next-line import/no-extraneous-dependencies
4 | import throttle from 'lodash.throttle';
5 | import { unByKey } from 'ol/Observable';
6 | import Control from './control';
7 | import image from '../../img/modify_geometry2.svg';
8 | import SelectMove from '../interaction/selectmove';
9 | import SelectModify from '../interaction/selectmodify';
10 | import Move from '../interaction/move';
11 | import Delete from '../interaction/delete';
12 |
13 | /**
14 | * Control for modifying geometries.
15 | * @extends {Control}
16 | * @alias ole.ModifyControl
17 | */
18 | class ModifyControl extends Control {
19 | /**
20 | * @param {Object} [options] Tool options.
21 | * @param {number} [options.hitTolerance=5] Select tolerance in pixels.
22 | * @param {ol.Collection} [options.features] Destination for drawing.
23 | * @param {ol.source.Vector} [options.source] Destination for drawing.
24 | * @param {Object} [options.selectMoveOptions] Options for the select interaction used to move features.
25 | * @param {Object} [options.selectModifyOptions] Options for the select interaction used to modify features.
26 | * @param {Object} [options.moveInteractionOptions] Options for the move interaction.
27 | * @param {Object} [options.modifyInteractionOptions] Options for the modify interaction.
28 | * @param {Object} [options.deleteInteractionOptions] Options for the delete interaction.
29 | * @param {Object} [options.deselectInteractionOptions] Options for the deselect interaction. Default: features are deselected on click on map.
30 | * @param {Function} [options.cursorStyleHandler] Options to override default cursor styling behavior.
31 | */
32 | constructor(options = {}) {
33 | super({
34 | title: 'Modify geometry',
35 | className: 'ole-control-modify',
36 | image,
37 | ...options,
38 | });
39 |
40 | /**
41 | * Buffer around the coordintate clicked in pixels.
42 | * @type {number}
43 | * @private
44 | */
45 | this.hitTolerance =
46 | options.hitTolerance === undefined ? 5 : options.hitTolerance;
47 |
48 | /**
49 | * Filter function to determine which features are elligible for selection.
50 | * By default we exclude features on unmanaged layers(for ex: nodes to delete).
51 | * @type {function(ol.Feature, ol.layer.Layer)}
52 | * @private
53 | */
54 | this.selectFilter =
55 | options.selectFilter ||
56 | ((feature, layer) => {
57 | if (layer && this.layerFilter) {
58 | return this.layerFilter(layer);
59 | }
60 | return !!layer;
61 | });
62 |
63 | /**
64 | *
65 | * Return features elligible for selection on specific pixel.
66 | * @type {function(ol.events.MapBrowserEvent)}
67 | * @private
68 | */
69 | this.getFeatureAtPixel = this.getFeatureAtPixel.bind(this);
70 |
71 | /* Cursor management */
72 | this.previousCursor = null;
73 | this.cursorTimeout = null;
74 | this.cursorHandlerThrottled = throttle(this.cursorHandler.bind(this), 150, {
75 | leading: true,
76 | });
77 | this.cursorStyleHandler =
78 | options?.cursorStyleHandler || ((cursorStyle) => cursorStyle);
79 |
80 | /* Interactions */
81 | this.createSelectMoveInteraction(options.selectMoveOptions);
82 | this.createSelectModifyInteraction(options.selectModifyOptions);
83 | this.createModifyInteraction(options.modifyInteractionOptions);
84 | this.createMoveInteraction(options.moveInteractionOptions);
85 | this.createDeleteInteraction(options.deleteInteractionOptions);
86 | this.createDeselectInteraction(options.deselectInteractionOptions);
87 | }
88 |
89 | /**
90 | * Create the interaction used to select feature to move.
91 | * @param {*} options
92 | * @private
93 | */
94 | createSelectMoveInteraction(options = {}) {
95 | /**
96 | * Select interaction to move features.
97 | * @type {ol.interaction.Select}
98 | * @private
99 | */
100 | this.selectMove = new SelectMove({
101 | filter: (feature, layer) => {
102 | // If the feature is already selected by modify interaction ignore the selection.
103 | if (this.isSelectedByModify(feature)) {
104 | return false;
105 | }
106 | return this.selectFilter(feature, layer);
107 | },
108 | hitTolerance: this.hitTolerance,
109 | ...options,
110 | });
111 |
112 | this.selectMove.getFeatures().on('add', () => {
113 | this.selectModify.getFeatures().clear();
114 | this.moveInteraction.setActive(true);
115 | this.deleteInteraction.setFeatures(this.selectMove.getFeatures());
116 | });
117 |
118 | this.selectMove.getFeatures().on('remove', () => {
119 | // Deactive interaction when the select array is empty
120 | if (this.selectMove.getFeatures().getLength() === 0) {
121 | this.moveInteraction.setActive(false);
122 | this.deleteInteraction.setFeatures();
123 | }
124 | });
125 | this.selectMove.setActive(false);
126 | }
127 |
128 | /**
129 | * Create the interaction used to select feature to modify.
130 | * @param {*} options
131 | * @private
132 | */
133 | createSelectModifyInteraction(options = {}) {
134 | /**
135 | * Select interaction to modify features.
136 | * @type {ol.interaction.Select}
137 | */
138 | this.selectModify = new SelectModify({
139 | filter: this.selectFilter,
140 | hitTolerance: this.hitTolerance,
141 | ...options,
142 | });
143 |
144 | this.selectModify.getFeatures().on('add', () => {
145 | this.selectMove.getFeatures().clear();
146 | this.modifyInteraction.setActive(true);
147 | this.deleteInteraction.setFeatures(this.selectModify.getFeatures());
148 | });
149 |
150 | this.selectModify.getFeatures().on('remove', () => {
151 | // Deactive interaction when the select array is empty
152 | if (this.selectModify.getFeatures().getLength() === 0) {
153 | this.modifyInteraction.setActive(false);
154 | this.deleteInteraction.setFeatures();
155 | }
156 | });
157 | this.selectModify.setActive(false);
158 | }
159 |
160 | /**
161 | * Create the interaction used to move feature.
162 | * @param {*} options
163 | * @private
164 | */
165 | createMoveInteraction(options = {}) {
166 | /**
167 | * @type {ole.interaction.Move}
168 | * @private
169 | */
170 | this.moveInteraction = new Move({
171 | features: this.selectMove.getFeatures(),
172 | ...options,
173 | });
174 |
175 | this.moveInteraction.on('movestart', (evt) => {
176 | this.editor.setEditFeature(evt.feature);
177 | this.isMoving = true;
178 | });
179 |
180 | this.moveInteraction.on('moveend', () => {
181 | this.editor.setEditFeature();
182 | this.isMoving = false;
183 | });
184 | this.moveInteraction.setActive(false);
185 | }
186 |
187 | /**
188 | * Create the interaction used to modify vertexes of features.
189 | * @param {*} options
190 | * @private
191 | */
192 | createModifyInteraction(options = {}) {
193 | /**
194 | * @type {ol.interaction.Modify}
195 | * @private
196 | */
197 | this.modifyInteraction = new Modify({
198 | features: this.selectModify.getFeatures(),
199 | deleteCondition: singleClick,
200 | ...options,
201 | });
202 |
203 | this.modifyInteraction.on('modifystart', (evt) => {
204 | this.editor.setEditFeature(evt.features.item(0));
205 | this.isModifying = true;
206 | });
207 |
208 | this.modifyInteraction.on('modifyend', () => {
209 | this.editor.setEditFeature();
210 | this.isModifying = false;
211 | });
212 | this.modifyInteraction.setActive(false);
213 | }
214 |
215 | /**
216 | * Create the interaction used to delete selected features.
217 | * @param {*} options
218 | * @private
219 | */
220 | createDeleteInteraction(options = {}) {
221 | /**
222 | * @type {ol.interaction.Delete}
223 | * @private
224 | */
225 | this.deleteInteraction = new Delete({ source: this.source, ...options });
226 |
227 | this.deleteInteraction.on('delete', () => {
228 | this.changeCursor(null);
229 | });
230 | this.deleteInteraction.setActive(false);
231 | }
232 |
233 | /**
234 | * Create the interaction used to deselected features when we click on the map.
235 | * @param {*} options
236 | * @private
237 | */
238 | createDeselectInteraction(options = {}) {
239 | // it's important that this condition was the same as the selectModify's
240 | // deleteCondition to avoid the selection of the feature under the node to delete.
241 | const condition = options.condition || singleClick;
242 |
243 | /**
244 | * @type {ol.interaction.Interaction}
245 | * @private
246 | */
247 | this.deselectInteraction = new Interaction({
248 | handleEvent: (mapBrowserEvent) => {
249 | if (!condition(mapBrowserEvent)) {
250 | return true;
251 | }
252 | const onFeature = this.getFeatureAtPixel(mapBrowserEvent.pixel);
253 | const onVertex = this.isHoverVertexFeatureAtPixel(
254 | mapBrowserEvent.pixel,
255 | );
256 |
257 | if (!onVertex && !onFeature) {
258 | // Default: Clear selection on click outside features.
259 | this.selectMove.getFeatures().clear();
260 | this.selectModify.getFeatures().clear();
261 | return false;
262 | }
263 | return true;
264 | },
265 | });
266 | this.deselectInteraction.setActive(false);
267 | }
268 |
269 | /**
270 | * Get a selectable feature at a pixel.
271 | * @param {*} pixel
272 | */
273 | getFeatureAtPixel(pixel) {
274 | const feature = this.map.forEachFeatureAtPixel(
275 | pixel,
276 | (feat, layer) => {
277 | if (this.selectFilter(feat, layer)) {
278 | return feat;
279 | }
280 | return null;
281 | },
282 | {
283 | hitTolerance: this.hitTolerance,
284 | layerFilter: this.layerFilter,
285 | },
286 | );
287 | return feature;
288 | }
289 |
290 | /**
291 | * Detect if a vertex is hovered.
292 | * @param {*} pixel
293 | */
294 | isHoverVertexFeatureAtPixel(pixel) {
295 | let isHoverVertex = false;
296 | this.map.forEachFeatureAtPixel(
297 | pixel,
298 | (feat, layer) => {
299 | if (!layer) {
300 | isHoverVertex = true;
301 | return true;
302 | }
303 | return false;
304 | },
305 | {
306 | hitTolerance: this.hitTolerance,
307 | },
308 | );
309 | return isHoverVertex;
310 | }
311 |
312 | isSelectedByMove(feature) {
313 | return this.selectMove.getFeatures().getArray().indexOf(feature) !== -1;
314 | }
315 |
316 | isSelectedByModify(feature) {
317 | return this.selectModify.getFeatures().getArray().indexOf(feature) !== -1;
318 | }
319 |
320 | /**
321 | * Handle the move event of the move interaction.
322 | * @param {ol.MapBrowserEvent} evt Event.
323 | * @private
324 | */
325 | cursorHandler(evt) {
326 | if (evt.dragging || this.isMoving || this.isModifying) {
327 | this.changeCursor('grabbing');
328 | return;
329 | }
330 |
331 | const feature = this.getFeatureAtPixel(evt.pixel);
332 | if (!feature) {
333 | this.changeCursor(this.previousCursor);
334 | this.previousCursor = null;
335 | return;
336 | }
337 |
338 | if (this.isSelectedByMove(feature)) {
339 | this.changeCursor('grab');
340 | } else if (this.isSelectedByModify(feature)) {
341 | if (this.isHoverVertexFeatureAtPixel(evt.pixel)) {
342 | this.changeCursor('grab');
343 | } else {
344 | this.changeCursor(this.previousCursor);
345 | }
346 | } else {
347 | // Feature available for selection.
348 | this.changeCursor('pointer');
349 | }
350 | }
351 |
352 | /**
353 | * Change cursor style.
354 | * @param {string} cursor New cursor name.
355 | * @private
356 | */
357 | changeCursor(cursor) {
358 | if (!this.getActive()) {
359 | return;
360 | }
361 | const newCursor = this.cursorStyleHandler(cursor);
362 | const element = this.map.getViewport();
363 | if (
364 | (element.style.cursor || newCursor) &&
365 | element.style.cursor !== newCursor
366 | ) {
367 | if (this.previousCursor === null) {
368 | this.previousCursor = element.style.cursor;
369 | }
370 | element.style.cursor = newCursor;
371 | }
372 | }
373 |
374 | setMap(map) {
375 | if (this.map) {
376 | this.map.removeInteraction(this.modifyInteraction);
377 | this.map.removeInteraction(this.moveInteraction);
378 | this.map.removeInteraction(this.selectMove);
379 | this.map.removeInteraction(this.selectModify);
380 | this.map.removeInteraction(this.deleteInteraction);
381 | this.map.removeInteraction(this.deselectInteraction);
382 | this.removeListeners();
383 | }
384 | super.setMap(map);
385 | if (this.getActive()) {
386 | this.addListeners();
387 | }
388 | this.map?.addInteraction(this.deselectInteraction);
389 | this.map?.addInteraction(this.deleteInteraction);
390 | this.map?.addInteraction(this.selectModify);
391 | // For the default behvior it's very important to add selectMove after selectModify.
392 | // It will avoid single/dbleclick mess.
393 | this.map?.addInteraction(this.selectMove);
394 | this.map?.addInteraction(this.moveInteraction);
395 | this.map?.addInteraction(this.modifyInteraction);
396 | }
397 |
398 | /**
399 | * Add others listeners on the map than interactions.
400 | * @param {*} evt
401 | * @private
402 | */
403 | addListeners() {
404 | this.removeListeners();
405 | this.cursorListenerKeys = [
406 | this.map?.on('pointerdown', (evt) => {
407 | const element = evt.map.getViewport();
408 | if (element?.style?.cursor === 'grab') {
409 | this.changeCursor('grabbing');
410 | }
411 | }),
412 | this.map?.on('pointermove', this.cursorHandlerThrottled),
413 | this.map?.on('pointerup', (evt) => {
414 | const element = evt.map.getViewport();
415 | if (element?.style?.cursor === 'grabbing') {
416 | this.changeCursor('grab');
417 | }
418 | }),
419 | ];
420 | }
421 |
422 | /**
423 | * Remove others listeners on the map than interactions.
424 | * @param {*} evt
425 | * @private
426 | */
427 | removeListeners() {
428 | unByKey(this.cursorListenerKeys);
429 | }
430 |
431 | /**
432 | * @inheritdoc
433 | */
434 | activate() {
435 | super.activate();
436 | this.deselectInteraction.setActive(true);
437 | this.deleteInteraction.setActive(true);
438 | this.selectModify.setActive(true);
439 | // For the default behavior it's very important to add selectMove after selectModify.
440 | // It will avoid single/dbleclick mess.
441 | this.selectMove.setActive(true);
442 | this.addListeners();
443 | }
444 |
445 | /**
446 | * @inheritdoc
447 | */
448 | deactivate(silent) {
449 | this.removeListeners();
450 | this.selectMove.getFeatures().clear();
451 | this.selectModify.getFeatures().clear();
452 | this.deselectInteraction.setActive(false);
453 | this.deleteInteraction.setActive(false);
454 | this.selectModify.setActive(false);
455 | this.selectMove.setActive(false);
456 | super.deactivate(silent);
457 | }
458 | }
459 |
460 | export default ModifyControl;
461 |
--------------------------------------------------------------------------------
/src/control/rotate.js:
--------------------------------------------------------------------------------
1 | import { Style, Icon } from 'ol/style';
2 | import Point from 'ol/geom/Point';
3 | import Vector from 'ol/layer/Vector';
4 | import VectorSource from 'ol/source/Vector';
5 | import Pointer from 'ol/interaction/Pointer';
6 | import Control from './control';
7 | import rotateSVG from '../../img/rotate.svg';
8 | import rotateMapSVG from '../../img/rotate_map.svg';
9 |
10 | /**
11 | * Tool with for rotating geometries.
12 | * @extends {Control}
13 | * @alias ole.RotateControl
14 | */
15 | class RotateControl extends Control {
16 | /**
17 | * @inheritdoc
18 | * @param {Object} [options] Control options.
19 | * @param {string} [options.rotateAttribute] Name of a feature attribute
20 | * that is used for storing the rotation in rad.
21 | * @param {ol.style.Style.StyleLike} [options.style] Style used for the rotation layer.
22 | */
23 | constructor(options) {
24 | super({
25 | title: 'Rotate',
26 | className: 'icon-rotate',
27 | image: rotateSVG,
28 | ...options,
29 | });
30 |
31 | /**
32 | * @type {ol.interaction.Pointer}
33 | * @private
34 | */
35 | this.pointerInteraction = new Pointer({
36 | handleDownEvent: this.onDown.bind(this),
37 | handleDragEvent: this.onDrag.bind(this),
38 | handleUpEvent: this.onUp.bind(this),
39 | });
40 |
41 | /**
42 | * @type {string}
43 | * @private
44 | */
45 | this.rotateAttribute = options.rotateAttribute || 'ole_rotation';
46 |
47 | /**
48 | * Layer for rotation feature.
49 | * @type {ol.layer.Vector}
50 | * @private
51 | */
52 | this.rotateLayer = new Vector({
53 | source: new VectorSource(),
54 | style:
55 | options.style ||
56 | ((f) => {
57 | const rotation = f.get(this.rotateAttribute);
58 | return [
59 | new Style({
60 | geometry: new Point(this.center),
61 | image: new Icon({
62 | rotation,
63 | src: rotateMapSVG,
64 | }),
65 | }),
66 | ];
67 | }),
68 | });
69 | }
70 |
71 | /**
72 | * Handle a pointer down event.
73 | * @param {ol.MapBrowserEvent} event Down event
74 | * @private
75 | */
76 | onDown(evt) {
77 | this.dragging = false;
78 | this.feature = this.map.forEachFeatureAtPixel(evt.pixel, (f) => {
79 | if (this.source.getFeatures().indexOf(f) > -1) {
80 | return f;
81 | }
82 |
83 | return null;
84 | });
85 |
86 | if (this.center && this.feature) {
87 | this.feature.set(
88 | this.rotateAttribute,
89 | this.feature.get(this.rotateAttribute) || 0,
90 | );
91 |
92 | // rotation between clicked coordinate and feature center
93 | this.initialRotation =
94 | Math.atan2(
95 | evt.coordinate[1] - this.center[1],
96 | evt.coordinate[0] - this.center[0],
97 | ) + this.feature.get(this.rotateAttribute);
98 | }
99 |
100 | if (this.feature) {
101 | return true;
102 | }
103 |
104 | return false;
105 | }
106 |
107 | /**
108 | * Handle a pointer drag event.
109 | * @param {ol.MapBrowserEvent} event Down event
110 | * @private
111 | */
112 | onDrag(evt) {
113 | this.dragging = true;
114 |
115 | if (this.feature && this.center) {
116 | const rotation = Math.atan2(
117 | evt.coordinate[1] - this.center[1],
118 | evt.coordinate[0] - this.center[0],
119 | );
120 |
121 | const rotationDiff = this.initialRotation - rotation;
122 | const geomRotation =
123 | rotationDiff - this.feature.get(this.rotateAttribute);
124 |
125 | this.feature.getGeometry().rotate(-geomRotation, this.center);
126 | this.rotateFeature.getGeometry().rotate(-geomRotation, this.center);
127 |
128 | this.feature.set(this.rotateAttribute, rotationDiff);
129 | this.rotateFeature.set(this.rotateAttribute, rotationDiff);
130 | }
131 | }
132 |
133 | /**
134 | * Handle a pointer up event.
135 | * @param {ol.MapBrowserEvent} event Down event
136 | * @private
137 | */
138 | onUp(evt) {
139 | if (!this.dragging) {
140 | if (this.feature) {
141 | this.rotateFeature = this.feature;
142 | this.center = evt.coordinate;
143 | this.rotateLayer.getSource().clear();
144 | this.rotateLayer.getSource().addFeature(this.rotateFeature);
145 | } else {
146 | this.rotateLayer.getSource().clear();
147 | }
148 | }
149 | }
150 |
151 | /**
152 | * @inheritdoc
153 | */
154 | activate() {
155 | this.map?.addInteraction(this.pointerInteraction);
156 | this.rotateLayer.setMap(this.map);
157 | super.activate();
158 | }
159 |
160 | /**
161 | * @inheritdoc
162 | */
163 | deactivate(silent) {
164 | this.rotateLayer.getSource().clear();
165 | this.rotateLayer.setMap(null);
166 | this.map?.removeInteraction(this.pointerInteraction);
167 | super.deactivate(silent);
168 | }
169 | }
170 |
171 | export default RotateControl;
172 |
--------------------------------------------------------------------------------
/src/control/toolbar.js:
--------------------------------------------------------------------------------
1 | import Control from 'ol/control/Control';
2 |
3 | /**
4 | * The editor's toolbar.
5 | * @class
6 | * @alias ole.Toolbar
7 | */
8 | class Toolbar extends Control {
9 | /**
10 | * Constructor.
11 | * @param {ol.Map} map The map object.
12 | * @param {ol.Collection.} controls Controls
13 | * @param {HTMLElement} [options.target] Specify a target if you want
14 | * the control to be rendered outside of the map's viewport.
15 | */
16 | constructor(map, controls, target) {
17 | const element = document.createElement('div');
18 | element.setAttribute('id', 'ole-toolbar');
19 |
20 | super({
21 | element: target || element,
22 | });
23 |
24 | /**
25 | * @private
26 | * @type {ol.Collection.}
27 | */
28 | this.controls = controls;
29 |
30 | /**
31 | * @private
32 | * @type {ol.Map}
33 | */
34 | this.map = map;
35 |
36 | if (!target) {
37 | this.map.getTargetElement().appendChild(this.element);
38 | }
39 |
40 | this.load();
41 | this.controls.on('change:length', this.load.bind(this));
42 | }
43 |
44 | /**
45 | * Load the toolbar.
46 | * @private
47 | */
48 | load() {
49 | for (let i = 0; i < this.controls.getLength(); i += 1) {
50 | const btn = this.controls.item(i).getElement();
51 | if (this.element && btn) {
52 | this.element.appendChild(btn);
53 | }
54 | }
55 | }
56 |
57 | /**
58 | * Destroy the toolbar.
59 | * @private
60 | */
61 | destroy() {
62 | for (let i = 0; i < this.controls.getLength(); i += 1) {
63 | const btn = this.controls.item(i).getElement();
64 | if (this.element && btn) {
65 | this.element.removeChild(btn);
66 | }
67 | }
68 | }
69 | }
70 |
71 | export default Toolbar;
72 |
--------------------------------------------------------------------------------
/src/control/topology.js:
--------------------------------------------------------------------------------
1 | import Select from 'ol/interaction/Select';
2 | import Control from './control';
3 | import delSVG from '../../img/buffer.svg';
4 |
5 | /**
6 | * Control for deleting geometries.
7 | * @extends {Control}
8 | * @alias ole.TopologyControl
9 | */
10 | class TopologyControl extends Control {
11 | /**
12 | * @inheritdoc
13 | * @param {Object} [options] Control options.
14 | * @param {number} [options.hitTolerance] Select tolerance in pixels
15 | * (default is 10)
16 | * @param {ol.style.Style.StyleLike} [options.style] Style used when a feature is selected.
17 | */
18 | constructor(options) {
19 | super({
20 | title: 'TopoloyOp',
21 | className: 'ole-control-topology',
22 | image: delSVG,
23 | ...options,
24 | });
25 |
26 | /**
27 | * @type {ol.interaction.Select}
28 | * @private
29 | */
30 | this.selectInteraction = new Select({
31 | toggleCondition: () => true,
32 | layers: this.layerFilter,
33 | hitTolerance:
34 | options.hitTolerance === undefined ? 10 : options.hitTolerance,
35 | multi: true,
36 | style: options.style,
37 | });
38 |
39 | this.selectInteraction.on('select', () => {
40 | const feats = this.selectInteraction.getFeatures();
41 |
42 | try {
43 | this.applyTopologyOperation(feats.getArray());
44 | } catch (ex) {
45 | // eslint-disable-next-line no-console
46 | console.error('Unable to process features.');
47 | feats.clear();
48 | }
49 | });
50 | }
51 |
52 | /**
53 | * Apply a topology operation for given features.
54 | * @param {Array.} features Features.
55 | */
56 | applyTopologyOperation(features) {
57 | this.topologyFeatures = features;
58 | }
59 |
60 | /**
61 | * @inheritdoc
62 | */
63 | activate() {
64 | this.map?.addInteraction(this.selectInteraction);
65 | this.addedFeatures = [];
66 | super.activate();
67 | }
68 |
69 | /**
70 | * @inheritdoc
71 | */
72 | deactivate(silent) {
73 | this.addedFeatures = [];
74 | this.map?.removeInteraction(this.selectInteraction);
75 | super.deactivate(silent);
76 | }
77 | }
78 |
79 | export default TopologyControl;
80 |
--------------------------------------------------------------------------------
/src/control/union.js:
--------------------------------------------------------------------------------
1 | import OL3Parser from 'jsts/org/locationtech/jts/io/OL3Parser';
2 | import { OverlayOp } from 'jsts/org/locationtech/jts/operation/overlay';
3 | import LinearRing from 'ol/geom/LinearRing';
4 | import {
5 | Point,
6 | LineString,
7 | Polygon,
8 | MultiPoint,
9 | MultiLineString,
10 | MultiPolygon,
11 | } from 'ol/geom';
12 | import TopologyControl from './topology';
13 | import unionSVG from '../../img/union.svg';
14 |
15 | /**
16 | * Control for creating a union of geometries.
17 | * @extends {Control}
18 | * @alias ole.Union
19 | */
20 | class Union extends TopologyControl {
21 | /**
22 | * @inheritdoc
23 | * @param {Object} [options] Control options.
24 | * @param {number} [options.hitTolerance] Select tolerance in pixels
25 | * (default is 10)
26 | */
27 | constructor(options) {
28 | super({
29 | title: 'Union',
30 | className: 'ole-control-union',
31 | image: unionSVG,
32 | ...options,
33 | });
34 | }
35 |
36 | /**
37 | * Apply a union for given features.
38 | * @param {Array.} features Features to union.
39 | */
40 | applyTopologyOperation(features) {
41 | super.applyTopologyOperation(features);
42 | const parser = new OL3Parser();
43 | parser.inject(
44 | Point,
45 | LineString,
46 | LinearRing,
47 | Polygon,
48 | MultiPoint,
49 | MultiLineString,
50 | MultiPolygon,
51 | );
52 |
53 | for (let i = 1; i < features.length; i += 1) {
54 | const geom = parser.read(features[0].getGeometry());
55 | const otherGeom = parser.read(features[i].getGeometry());
56 | const unionGeom = OverlayOp.union(geom, otherGeom);
57 | features[0].setGeometry(parser.write(unionGeom));
58 | features[i].setGeometry(null);
59 | }
60 | }
61 | }
62 |
63 | export default Union;
64 |
--------------------------------------------------------------------------------
/src/editor.js:
--------------------------------------------------------------------------------
1 | import Collection from 'ol/Collection';
2 | import BaseObject from 'ol/Object';
3 | import Toolbar from './control/toolbar';
4 |
5 | /**
6 | * Core component of OLE.
7 | * All controls are added to this class.
8 | */
9 | class Editor extends BaseObject {
10 | /**
11 | * Initialization of the editor.
12 | * @param {ol.Map} map The map object.
13 | * @param {Object} [options] Editor options.
14 | * @param {Boolean} [options.showToolbar] Whether to show the toolbar.
15 | * @param {HTMLElement} [options.target] Specify a target if you want
16 | * the toolbar to be rendered outside of the map's viewport.
17 | */
18 | constructor(map, opts) {
19 | super();
20 | /**
21 | * @private
22 | * @type {ol.Map}
23 | */
24 | this.map = map;
25 |
26 | /**
27 | * @private
28 | * @type {ol.Collection}
29 | */
30 | this.controls = new Collection();
31 |
32 | /**
33 | * @private
34 | * @type {ol.Collection}
35 | */
36 | this.activeControls = new Collection();
37 |
38 | /**
39 | * @private
40 | * @type {ol.Collection}
41 | */
42 | this.services = new Collection();
43 |
44 | /**
45 | * @private
46 | * @type {Object}
47 | */
48 | this.options = opts || {};
49 |
50 | /**
51 | * Feature that is currently edited.
52 | * @private
53 | * @type {ol.Feature}
54 | */
55 | this.editFeature = null;
56 |
57 | if (typeof this.options.showToolbar === 'undefined') {
58 | this.options.showToolbar = true;
59 | }
60 |
61 | if (this.options.showToolbar) {
62 | this.toolbar = new Toolbar(this.map, this.controls, this.options.target);
63 | }
64 |
65 | this.activeStateChange = this.activeStateChange.bind(this);
66 | }
67 |
68 | /**
69 | * Adds a new control to the editor.
70 | * @param {ole.Control} control The control.
71 | */
72 | addControl(control) {
73 | control.setMap(this.map);
74 | control.setEditor(this);
75 | control.addEventListener('change:active', this.activeStateChange);
76 | this.controls.push(control);
77 | }
78 |
79 | /**
80 | * Remove a control from the editor
81 | * @param {ole.Control} control The control.
82 | */
83 | removeControl(control) {
84 | control.deactivate();
85 | this.controls.remove(control);
86 | control.removeEventListener('change:active', this.activeStateChange);
87 | control.setEditor();
88 | control.setMap();
89 | }
90 |
91 | /**
92 | * Adds a service to the editor.
93 | */
94 | addService(service) {
95 | service.setMap(this.map);
96 | service.setEditor(this);
97 | service.activate();
98 | this.services.push(service);
99 | }
100 |
101 | /**
102 | * Adds a collection of controls to the editor.
103 | * @param {ol.Collection} controls Collection of controls.
104 | */
105 | addControls(controls) {
106 | const ctrls =
107 | controls instanceof Collection ? controls : new Collection(controls);
108 |
109 | for (let i = 0; i < ctrls.getLength(); i += 1) {
110 | this.addControl(ctrls.item(i));
111 | }
112 | }
113 |
114 | /**
115 | * Removes the editor from the map.
116 | */
117 | remove() {
118 | const controls = [...this.controls.getArray()];
119 | controls.forEach((control) => {
120 | this.removeControl(control);
121 | });
122 | if (this.toolbar) {
123 | this.toolbar.destroy();
124 | }
125 | }
126 |
127 | /**
128 | * Returns a list of ctive controls.
129 | * @returns {ol.Collection.} Active controls.
130 | */
131 | getControls() {
132 | return this.controls;
133 | }
134 |
135 | /**
136 | * Returns a list of active controls.
137 | * @returns {ol.Collection.} Active controls.
138 | */
139 | getActiveControls() {
140 | return this.activeControls;
141 | }
142 |
143 | /**
144 | * Sets an instance of the feature that is edited.
145 | * Some controls need information about the feature
146 | * that is currently edited (e.g. for not snapping on them).
147 | * @param {ol.Feature|null} feature The editfeature (or null if none)
148 | * @protected
149 | */
150 | setEditFeature(feature) {
151 | if (feature !== this.editFeature) {
152 | this.editFeature = feature;
153 | }
154 | }
155 |
156 | /**
157 | * Returns the feature that is currently edited.
158 | * @returns {ol.Feature|null} The edit feature.
159 | */
160 | getEditFeature() {
161 | return this.editFeature;
162 | }
163 |
164 | /**
165 | * Sets an instance of the feature that is being drawn.
166 | * Some controls need information about the feature
167 | * that is currently being drawn (e.g. for not snapping on them).
168 | * @param {ol.Feature|null} feature The drawFeature (or null if none).
169 | * @protected
170 | */
171 | setDrawFeature(feature) {
172 | if (feature !== this.drawFeature) {
173 | this.drawFeature = feature;
174 | }
175 | }
176 |
177 | /**
178 | * Returns the feature that is currently being drawn.
179 | * @returns {ol.Feature|null} The drawFeature.
180 | */
181 | getDrawFeature() {
182 | return this.drawFeature;
183 | }
184 |
185 | /**
186 | * Controls use this function for triggering activity state changes.
187 | * @param {ol.control.Control} control Control.
188 | * @private
189 | */
190 | activeStateChange(evt) {
191 | const ctrl = evt.detail.control;
192 | // Deactivate other controls that are not standalone
193 | if (ctrl.getActive() && ctrl.standalone) {
194 | for (let i = 0; i < this.controls.getLength(); i += 1) {
195 | const otherCtrl = this.controls.item(i);
196 | if (
197 | otherCtrl !== ctrl &&
198 | otherCtrl.getActive() &&
199 | otherCtrl.standalone
200 | ) {
201 | otherCtrl.deactivate();
202 | this.activeControls.remove(otherCtrl);
203 | }
204 | }
205 | }
206 |
207 | if (ctrl.getActive()) {
208 | this.activeControls.push(ctrl);
209 | } else {
210 | this.activeControls.remove(ctrl);
211 | }
212 | }
213 |
214 | get editFeature() {
215 | return this.get('editFeature');
216 | }
217 |
218 | set editFeature(feature) {
219 | this.set('editFeature', feature);
220 | }
221 |
222 | get drawFeature() {
223 | return this.get('drawFeature');
224 | }
225 |
226 | set drawFeature(feature) {
227 | this.set('drawFeature', feature);
228 | }
229 | }
230 |
231 | export default Editor;
232 |
--------------------------------------------------------------------------------
/src/editor.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | import { expect, test, describe, beforeEach } from 'vitest';
3 | import Map from 'ol/Map';
4 | import Editor from './editor';
5 | import CAD from './control/cad';
6 | import ModifyControl from './control/modify';
7 |
8 | describe('editor', () => {
9 | let map;
10 | let editor;
11 | let cad;
12 | let modify;
13 |
14 | beforeEach(() => {
15 | // In the test we use pixel as coordinates.
16 | map = new Map({
17 | target: document.createElement('div'),
18 | });
19 | editor = new Editor(map);
20 | cad = new CAD();
21 | modify = new ModifyControl();
22 | });
23 |
24 | test('adds a control', () => {
25 | editor.addControl(cad);
26 | expect(editor.controls.getArray()[0]).toBe(cad);
27 | expect(editor.activeControls.getLength()).toBe(0);
28 | expect(cad.map).toBe(map);
29 | expect(cad.editor).toBe(editor);
30 | expect(cad.getActive()).toBe();
31 |
32 | cad.activate();
33 | expect(cad.getActive()).toBe(true);
34 | expect(editor.activeControls.getArray()[0]).toBe(cad);
35 | });
36 |
37 | test('removes a control', () => {
38 | editor.addControl(cad);
39 | cad.activate();
40 | expect(cad.getActive()).toBe(true);
41 | expect(editor.controls.getArray()[0]).toBe(cad);
42 | expect(editor.activeControls.getArray()[0]).toBe(cad);
43 | editor.removeControl(cad);
44 | expect(editor.controls.getLength()).toBe(0);
45 | expect(editor.activeControls.getLength()).toBe(0);
46 | expect(cad.map).toBe();
47 | expect(cad.editor).toBe();
48 | expect(cad.getActive()).toBe(false);
49 | });
50 |
51 | test('is removed', () => {
52 | editor.addControl(modify);
53 | editor.addControl(cad);
54 | modify.activate();
55 | expect(modify.getActive()).toBe(true);
56 | expect(editor.controls.getArray()[0]).toBe(modify);
57 | expect(editor.controls.getArray()[1]).toBe(cad);
58 | expect(editor.activeControls.getArray()[0]).toBe(modify);
59 | expect(editor.activeControls.getArray()[0]).toBe(modify);
60 | editor.remove();
61 | expect(editor.controls.getLength()).toBe(0);
62 | expect(editor.activeControls.getLength()).toBe(0);
63 | expect(modify.map).toBe();
64 | expect(modify.editor).toBe();
65 | expect(modify.getActive()).toBe(false);
66 | expect(cad.map).toBe();
67 | expect(cad.editor).toBe();
68 | expect(cad.getActive()).toBe(false);
69 | });
70 | });
71 |
--------------------------------------------------------------------------------
/src/event/delete-event.js:
--------------------------------------------------------------------------------
1 | import Event from 'ol/events/Event';
2 |
3 | /**
4 | * Enum for delete event type.
5 | * @enum {string} DeleteEventType DELETE
6 | * @ignore
7 | */
8 | export const DeleteEventType = {
9 | /**
10 | * Triggered upon feature(s) is(are) deleted.
11 | * @type {string}
12 | */
13 | DELETE: 'delete',
14 | };
15 |
16 | /**
17 | * Events emitted by the snap interaction of cad control instances are
18 | * instances of this type.
19 | * @ignore
20 | */
21 | export default class DeleteEvent extends Event {
22 | /**
23 | * @inheritdoc
24 | * @param {DeleteEventType} type Type.
25 | * @param {Feature} feature The feature snapped.
26 | * @param {MapBrowserPointerEvent} mapBrowserPointerEvent
27 | * @ignore
28 | */
29 | constructor(type, features, mapBrowserPointerEvent) {
30 | super(type);
31 |
32 | /**
33 | * The features being deleted.
34 | * @type {Features}
35 | */
36 | this.features = features;
37 |
38 | /**
39 | * @type {MapBrowserPointerEvent}
40 | */
41 | this.mapBrowserEvent = mapBrowserPointerEvent;
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/event/index.js:
--------------------------------------------------------------------------------
1 | export { default as DeleteEvent } from './delete-event';
2 | export { default as MoveEvent } from './move-event';
3 | export { default as SnapEvent } from './snap-event';
4 | export * from './snap-event';
5 | export * from './move-event';
6 | export * from './delete-event';
7 |
--------------------------------------------------------------------------------
/src/event/move-event.js:
--------------------------------------------------------------------------------
1 | import Event from 'ol/events/Event';
2 |
3 | /**
4 | * @enum {string} MoveEventType
5 | * @ignore
6 | */
7 | export const MoveEventType = {
8 | /**
9 | * Triggered upon feature move start
10 | */
11 | MOVESTART: 'movestart',
12 |
13 | /**
14 | * Triggered upon feature move end
15 | */
16 | MOVEEND: 'moveend',
17 | };
18 |
19 | /**
20 | * Events emitted by the move interaction of modify control instances are
21 | * instances of this type.
22 | * @ignore
23 | */
24 | export default class MoveEvent extends Event {
25 | /**
26 | * @inheritdoc
27 | * @param {MoveEventType} type Type.
28 | * @param {Feature} feature The feature moved.
29 | * @param {MapBrowserPointerEvent} mapBrowserPointerEvent
30 | * @ignore
31 | */
32 | constructor(type, feature, mapBrowserPointerEvent) {
33 | super(type);
34 |
35 | /**
36 | * The features being modified.
37 | * @type {Feature}
38 | */
39 | this.feature = feature;
40 |
41 | /**
42 | * @type {MapBrowserPointerEvent}
43 | */
44 | this.mapBrowserEvent = mapBrowserPointerEvent;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/event/snap-event.js:
--------------------------------------------------------------------------------
1 | import Event from 'ol/events/Event';
2 |
3 | /**
4 | * Enum for snap event type.
5 | * @enum {string} SnapEventType SNAP
6 | * @ignore
7 | */
8 | export const SnapEventType = {
9 | /**
10 | * Triggered upon feature is snapped.
11 | * @type {string}
12 | */
13 | SNAP: 'snap',
14 | };
15 |
16 | /**
17 | * Events emitted by the snap interaction of cad control instances are
18 | * instances of this type.
19 | * @ignore
20 | */
21 | export default class SnapEvent extends Event {
22 | /**
23 | * @inheritdoc
24 | * @param {SnapEventType} type Type.
25 | * @param {Feature} feature The feature snapped.
26 | * @param {MapBrowserPointerEvent} mapBrowserPointerEvent
27 | * @ignore
28 | */
29 | constructor(type, features, mapBrowserPointerEvent) {
30 | super(type);
31 |
32 | /**
33 | * The features being snapped.
34 | * @type {Features}
35 | */
36 | this.features = features;
37 |
38 | /**
39 | * @type {MapBrowserPointerEvent}
40 | */
41 | this.mapBrowserEvent = mapBrowserPointerEvent;
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/helper/constants.js:
--------------------------------------------------------------------------------
1 | import { Fill, RegularShape, Stroke, Style } from 'ol/style';
2 |
3 | export const ORTHO_LINE_KEY = 'ortho';
4 | export const SEGMENT_LINE_KEY = 'segment';
5 | export const VH_LINE_KEY = 'vh';
6 | export const CUSTOM_LINE_KEY = 'custom';
7 | export const SNAP_POINT_KEY = 'point';
8 | export const SNAP_FEATURE_TYPE_PROPERTY = 'ole.snap.feature.type';
9 |
10 | export const defaultSnapStyles = {
11 | [ORTHO_LINE_KEY]: new Style({
12 | stroke: new Stroke({
13 | width: 1,
14 | color: 'purple',
15 | lineDash: [5, 10],
16 | }),
17 | }),
18 | [SEGMENT_LINE_KEY]: new Style({
19 | stroke: new Stroke({
20 | width: 1,
21 | color: 'orange',
22 | lineDash: [5, 10],
23 | }),
24 | }),
25 | [VH_LINE_KEY]: new Style({
26 | stroke: new Stroke({
27 | width: 1,
28 | lineDash: [5, 10],
29 | color: '#618496',
30 | }),
31 | }),
32 | [SNAP_POINT_KEY]: new Style({
33 | image: new RegularShape({
34 | fill: new Fill({
35 | color: '#E8841F',
36 | }),
37 | stroke: new Stroke({
38 | width: 1,
39 | color: '#618496',
40 | }),
41 | points: 4,
42 | radius: 5,
43 | radius2: 0,
44 | angle: Math.PI / 4,
45 | }),
46 | }),
47 | };
48 |
--------------------------------------------------------------------------------
/src/helper/getDistance.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Returns the distance between 2 coordinates of a plan.
3 | *
4 | * @param {Array} coordA
5 | * @param {Array} coordB
6 | * @returns number
7 | */
8 | const getDistance = (coordA, coordB) => {
9 | const [xA, yA] = coordA;
10 | const [xB, yB] = coordB;
11 | return Math.sqrt((xB - xA) ** 2 + (yB - yA) ** 2);
12 | };
13 |
14 | export default getDistance;
15 |
--------------------------------------------------------------------------------
/src/helper/getEquationOfLine.js:
--------------------------------------------------------------------------------
1 | // Get the equation "y = mx + b" of line containing A and B
2 | // where m = (yB-yA)/(xB-xA)
3 | // an b = yB - mXB;
4 | const getEquationOfLine = (coordA, coordB) => {
5 | const [xA, yA] = coordA;
6 | const [xB, yB] = coordB;
7 | if (xB - xA === 0) {
8 | // No division by 0
9 | return null;
10 | }
11 | const m = (yB - yA) / (xB - xA);
12 | const b = yB - m * xB;
13 | return (x) => m * x + b;
14 | };
15 |
16 | export default getEquationOfLine;
17 |
--------------------------------------------------------------------------------
/src/helper/getIntersectedLinesAndPoint.js:
--------------------------------------------------------------------------------
1 | import { OverlayOp } from 'jsts/org/locationtech/jts/operation/overlay';
2 | import { Feature } from 'ol';
3 | import { Point } from 'ol/geom';
4 | import { SNAP_FEATURE_TYPE_PROPERTY, SNAP_POINT_KEY } from './constants';
5 | import getDistance from './getDistance';
6 | import isSameLines from './isSameLines';
7 | import parser from './parser';
8 |
9 | // Find lines that intersects and calculate the intersection point.
10 | // Return only point (and corresponding lines) that are distant from the mouse coordinate < snapTolerance
11 | const getIntersectedLinesAndPoint = (coordinate, lines, map, snapTolerance) => {
12 | const liness = [];
13 | const points = [];
14 | const isAlreadyIntersected = [];
15 | const isPointAlreadyExist = {};
16 | const mousePx = map.getPixelFromCoordinate(coordinate);
17 |
18 | const parsedLines = lines.map((line) => [
19 | line,
20 | parser.read(line.getGeometry()),
21 | ]);
22 | parsedLines.forEach(([lineA, parsedLineA]) => {
23 | parsedLines.forEach(([lineB, parsedLineB]) => {
24 | if (lineA === lineB || isSameLines(lineA, lineB, map)) {
25 | return;
26 | }
27 |
28 | let intersections;
29 | try {
30 | intersections = OverlayOp.intersection(parsedLineA, parsedLineB);
31 | } catch (e) {
32 | return; // The OverlayOp will sometimes error with topology errors for certain lines
33 | }
34 |
35 | const coord = intersections?.getCoordinates()[0];
36 | if (coord) {
37 | intersections.getCoordinates().forEach(({ x, y }) => {
38 | if (
39 | getDistance(map.getPixelFromCoordinate([x, y]), mousePx) <=
40 | snapTolerance
41 | ) {
42 | // Add lines only when the intersecting point is valid for snapping
43 | if (!isAlreadyIntersected.includes(lineA)) {
44 | liness.push(lineA);
45 | isAlreadyIntersected.push(lineA);
46 | }
47 |
48 | if (!isAlreadyIntersected.includes(lineB)) {
49 | liness.push(lineB);
50 | isAlreadyIntersected.push(lineB);
51 | }
52 |
53 | if (!isPointAlreadyExist[`${x}${y}`]) {
54 | isPointAlreadyExist[`${x}${y}`] = true;
55 | const feature = new Feature(new Point([x, y]));
56 | feature.set(SNAP_FEATURE_TYPE_PROPERTY, SNAP_POINT_KEY);
57 | points.push(feature);
58 | }
59 | }
60 | });
61 | }
62 | });
63 | });
64 |
65 | return [...liness, ...points];
66 | };
67 |
68 | export default getIntersectedLinesAndPoint;
69 |
--------------------------------------------------------------------------------
/src/helper/getIntersectedLinesAndPoint.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | import { expect, test, describe, beforeEach } from 'vitest';
3 | import LineString from 'ol/geom/LineString';
4 | import Feature from 'ol/Feature';
5 | import getIntersectedLinesAndPoint from './getIntersectedLinesAndPoint';
6 |
7 | describe('getIntersectedLinesAndPoint', () => {
8 | let map;
9 |
10 | beforeEach(() => {
11 | // In the test we use pixel as coordinates.
12 | map = {
13 | getPixelFromCoordinate: (coord) => coord,
14 | };
15 | });
16 |
17 | test('returns empty array because lines are not intersected', () => {
18 | const line1 = new Feature(
19 | new LineString([
20 | [0, 0],
21 | [1, 1],
22 | ]),
23 | );
24 | const line2 = new Feature(
25 | new LineString([
26 | [3, 4],
27 | [5, 7],
28 | ]),
29 | );
30 |
31 | const intersectedLines = getIntersectedLinesAndPoint(
32 | [0, 0],
33 | [line1, line2],
34 | map,
35 | 0,
36 | );
37 |
38 | expect(intersectedLines).toEqual([]);
39 | });
40 |
41 | test('returns empty array because the tolerance is not big enough', () => {
42 | const line1 = new Feature(
43 | new LineString([
44 | [0, 0],
45 | [1, 1],
46 | ]),
47 | );
48 | const line2 = new Feature(
49 | new LineString([
50 | [0, 1],
51 | [1, 0],
52 | ]),
53 | );
54 |
55 | const intersectedLines = getIntersectedLinesAndPoint(
56 | [0, 0],
57 | [line1, line2],
58 | map,
59 | 0,
60 | );
61 |
62 | expect(intersectedLines).toEqual([]);
63 | });
64 |
65 | test('returns intersected lines and the intersection point', () => {
66 | const line1 = new Feature(
67 | new LineString([
68 | [0, 0],
69 | [1, 1],
70 | ]),
71 | );
72 | const line2 = new Feature(
73 | new LineString([
74 | [0, 1],
75 | [1, 0],
76 | ]),
77 | );
78 |
79 | const intersectedLines = getIntersectedLinesAndPoint(
80 | [0, 0],
81 | [line1, line2],
82 | map,
83 | 1,
84 | );
85 | expect(intersectedLines[0]).toBe(line1);
86 | expect(intersectedLines[1]).toBe(line2);
87 | expect(intersectedLines[2].getGeometry().getCoordinates()).toEqual([
88 | 0.5, 0.5,
89 | ]);
90 | });
91 | });
92 |
--------------------------------------------------------------------------------
/src/helper/getProjectedPoint.js:
--------------------------------------------------------------------------------
1 | const dotProduct = (e1, e2) => e1[0] * e2[0] + e1[1] * e2[1];
2 |
3 | /**
4 | * Get projected point P' of P on line e1. Faster version.
5 | * @return projected point p.
6 | * This code comes from section 5 of http://www.sunshine2k.de/coding/java/PointOnLine/PointOnLine.html.
7 | * The dotProduct function had a bug in the html page. It's fixed here.
8 | */
9 | const getProjectedPoint = (p, v1, v2) => {
10 | // get dot product of e1, e2
11 | const e1 = [v2[0] - v1[0], v2[1] - v1[1]];
12 | const e2 = [p[0] - v1[0], p[1] - v1[1]];
13 | const valDp = dotProduct(e1, e2);
14 |
15 | // get squared length of e1
16 | const len2 = e1[0] * e1[0] + e1[1] * e1[1];
17 | const res = [v1[0] + (valDp * e1[0]) / len2, v1[1] + (valDp * e1[1]) / len2];
18 | return res;
19 | };
20 | export default getProjectedPoint;
21 |
--------------------------------------------------------------------------------
/src/helper/getShiftedMultiPoint.js:
--------------------------------------------------------------------------------
1 | import { MultiPoint } from 'ol/geom';
2 |
3 | /**
4 | * Removes the closest node to a given coordinate from a given geometry.
5 | * @private
6 | * @param {ol.Geometry} geometry An openlayers geometry.
7 | * @param {ol.Coordinate} coordinate Coordinate.
8 | * @returns {ol.Geometry.MultiPoint} An openlayers MultiPoint geometry.
9 | */
10 | const getShiftedMultipoint = (geometry, coordinate) => {
11 | // Include all but the closest vertex to the coordinate (e.g. at mouse position)
12 | // to prevent snapping on mouse cursor node
13 | const isPolygon = geometry.getType() === 'Polygon';
14 | const shiftedMultipoint = new MultiPoint(
15 | isPolygon ? geometry.getCoordinates()[0] : geometry.getCoordinates(),
16 | );
17 |
18 | const drawNodeCoordinate = shiftedMultipoint.getClosestPoint(coordinate);
19 |
20 | // Exclude the node being modified
21 | shiftedMultipoint.setCoordinates(
22 | shiftedMultipoint
23 | .getCoordinates()
24 | .filter((coord) => coord.toString() !== drawNodeCoordinate.toString()),
25 | );
26 |
27 | return shiftedMultipoint;
28 | };
29 |
30 | export default getShiftedMultipoint;
31 |
--------------------------------------------------------------------------------
/src/helper/index.js:
--------------------------------------------------------------------------------
1 | export { default as getEquationOfLine } from './getEquationOfLine';
2 | export { default as getIntersectedLinesAndPoint } from './getIntersectedLinesAndPoint';
3 | export { default as getProjectedPoint } from './getProjectedPoint';
4 | export { default as getShiftedMultiPoint } from './getShiftedMultiPoint';
5 | export { default as isSameLines } from './isSameLines';
6 | export { default as parser } from './parser';
7 | export * from './constants';
8 |
--------------------------------------------------------------------------------
/src/helper/isSameLines.js:
--------------------------------------------------------------------------------
1 | // We consider 2 lines identical when 2 lines have the same equation when the use their pixel values not coordinate.
2 | // Using the coordinate the calculation is falsy because of some rounding.
3 |
4 | import getEquationOfLine from './getEquationOfLine';
5 |
6 | // This function compares only 2 lines of 2 coordinates.
7 | const isSameLines = (lineA, lineB, map) => {
8 | const geomA = lineA.getGeometry();
9 | const firstPxA = map.getPixelFromCoordinate(geomA.getFirstCoordinate());
10 | const lastPxA = map.getPixelFromCoordinate(geomA.getLastCoordinate());
11 | const lineFuncA = getEquationOfLine(firstPxA, lastPxA);
12 |
13 | const geomB = lineB.getGeometry();
14 | const firstPxB = map.getPixelFromCoordinate(geomB.getFirstCoordinate());
15 | const lastPxB = map.getPixelFromCoordinate(geomB.getLastCoordinate());
16 | const lineFuncB = getEquationOfLine(firstPxB, lastPxB);
17 |
18 | // We compare with toFixed(2) becaus eof rounding issues converting pixel to coordinate.
19 | if (
20 | lineFuncA &&
21 | lineFuncB &&
22 | lineFuncA(-350).toFixed(2) === lineFuncB(-350).toFixed(2) &&
23 | lineFuncA(7800).toFixed(2) === lineFuncB(7800).toFixed(2)
24 | ) {
25 | return true;
26 | }
27 |
28 | // 2 are vertical lines
29 | if (!lineFuncA && !lineFuncB && firstPxA[0] && firstPxB[0]) {
30 | return true;
31 | }
32 |
33 | return false;
34 | };
35 |
36 | export default isSameLines;
37 |
--------------------------------------------------------------------------------
/src/helper/isSameLines.test.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | import { expect, test, describe, beforeEach } from 'vitest';
3 | import LineString from 'ol/geom/LineString';
4 | import Feature from 'ol/Feature';
5 | import isSameLines from './isSameLines';
6 |
7 | describe('isSameLines', () => {
8 | let map;
9 |
10 | beforeEach(() => {
11 | // In the test we use pixel as coordinates.
12 | map = {
13 | getPixelFromCoordinate: (coord) => coord,
14 | };
15 | });
16 |
17 | test('returns false', () => {
18 | const line1 = new Feature(
19 | new LineString([
20 | [0, 0],
21 | [1, 1],
22 | ]),
23 | );
24 | const line2 = new Feature(
25 | new LineString([
26 | [3, 4],
27 | [5, 7],
28 | ]),
29 | );
30 |
31 | const isSameLine = isSameLines(line1, line2, map);
32 | expect(isSameLine).toBe(false);
33 | });
34 |
35 | test('returns true', () => {
36 | const line1 = new Feature(
37 | new LineString([
38 | [0, 0],
39 | [1, 1],
40 | ]),
41 | );
42 | const line2 = new Feature(
43 | new LineString([
44 | [2, 2],
45 | [3, 3],
46 | ]),
47 | );
48 |
49 | const isSameLine = isSameLines(line1, line2, map);
50 | expect(isSameLine).toBe(true);
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/src/helper/parser.js:
--------------------------------------------------------------------------------
1 | import OL3Parser from 'jsts/org/locationtech/jts/io/OL3Parser';
2 | import { LineString, MultiPoint, Point, Polygon } from 'ol/geom';
3 |
4 | // Create a JSTS parser for OpenLayers geometry.
5 | const parser = new OL3Parser();
6 | parser.inject(Point, LineString, Polygon, MultiPoint);
7 |
8 | export default parser;
9 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | export { default as Editor } from './editor';
2 | export * as control from './control';
3 | export * as service from './service';
4 | export * as interaction from './interaction';
5 | export * as helper from './helper';
6 |
--------------------------------------------------------------------------------
/src/interaction/delete.js:
--------------------------------------------------------------------------------
1 | import Interaction from 'ol/interaction/Interaction';
2 | import EventType from 'ol/events/EventType';
3 | import { noModifierKeys, targetNotEditable } from 'ol/events/condition';
4 | import DeleteEvent, { DeleteEventType } from '../event/delete-event';
5 |
6 | class Delete extends Interaction {
7 | constructor(options = {}) {
8 | super(options);
9 |
10 | this.source = options.source;
11 |
12 | this.features = options.features;
13 |
14 | this.condition =
15 | options.condition ||
16 | ((mapBrowserEvent) => {
17 | const bool =
18 | noModifierKeys(mapBrowserEvent) &&
19 | targetNotEditable(mapBrowserEvent) &&
20 | (mapBrowserEvent.originalEvent.keyCode === 46 ||
21 | mapBrowserEvent.originalEvent.keyCode === 8);
22 | return bool;
23 | });
24 | }
25 |
26 | setFeatures(features) {
27 | this.features = features;
28 | }
29 |
30 | handleEvent(mapBrowserEvent) {
31 | let stopEvent = false;
32 | if (
33 | (mapBrowserEvent.type === EventType.KEYDOWN ||
34 | mapBrowserEvent.type === EventType.KEYPRESS) &&
35 | this.condition(mapBrowserEvent) &&
36 | this.features
37 | ) {
38 | // Loop delete through selected features array
39 | this.features
40 | .getArray()
41 | .forEach((feature) => this.source.removeFeature(feature));
42 |
43 | this.dispatchEvent(
44 | new DeleteEvent(DeleteEventType.DELETE, this.features, mapBrowserEvent),
45 | );
46 |
47 | // Clean select's collection
48 | this.features.clear();
49 | stopEvent = true;
50 | }
51 | return !stopEvent;
52 | }
53 | }
54 |
55 | export default Delete;
56 |
--------------------------------------------------------------------------------
/src/interaction/index.js:
--------------------------------------------------------------------------------
1 | export { default as Delete } from './delete';
2 | export { default as SelectMove } from './selectmove';
3 | export { default as SelectModify } from './selectmodify';
4 | export { default as Move } from './move';
5 |
--------------------------------------------------------------------------------
/src/interaction/move.js:
--------------------------------------------------------------------------------
1 | import Pointer from 'ol/interaction/Pointer';
2 | import Point from 'ol/geom/Point';
3 | import { getCenter } from 'ol/extent';
4 | import MoveEvent, { MoveEventType } from '../event/move-event';
5 |
6 | class Move extends Pointer {
7 | constructor(options) {
8 | super();
9 | this.features = options.features;
10 | }
11 |
12 | /**
13 | * Handle the down event of the move interaction.
14 | * @param {ol.MapBrowserEvent} evt Event.
15 | * @private
16 | */
17 | handleDownEvent(evt) {
18 | [this.featureToMove] = evt.map.getFeaturesAtPixel(evt.pixel);
19 | if (
20 | !this.featureToMove ||
21 | !this.features.getArray().includes(this.featureToMove)
22 | ) {
23 | return false;
24 | }
25 |
26 | if (this.featureToMove.getGeometry() instanceof Point) {
27 | const extent = this.featureToMove.getGeometry().getExtent();
28 | this.coordinate = getCenter(extent);
29 | } else {
30 | this.coordinate = evt.coordinate;
31 | }
32 | this.isMoving = true;
33 | this.dispatchEvent(
34 | new MoveEvent(MoveEventType.MOVESTART, this.featureToMove, evt),
35 | );
36 |
37 | return true;
38 | }
39 |
40 | /**
41 | * Handle the drag event of the move interaction.
42 | * @param {ol.MapBrowserEvent} evt Event.
43 | * @private
44 | */
45 | handleDragEvent(evt) {
46 | const deltaX = evt.coordinate[0] - this.coordinate[0];
47 | const deltaY = evt.coordinate[1] - this.coordinate[1];
48 |
49 | this.featureToMove.getGeometry().translate(deltaX, deltaY);
50 | this.coordinate = evt.coordinate;
51 | }
52 |
53 | /**
54 | * Handle the up event of the pointer interaction.
55 | * @param {ol.MapBrowserEvent} evt Event.
56 | * @private
57 | */
58 | handleUpEvent(evt) {
59 | this.dispatchEvent(
60 | new MoveEvent(MoveEventType.MOVEEND, this.featureToMove, evt),
61 | );
62 | this.coordinate = null;
63 | this.isMoving = false;
64 | this.featureToMove = null;
65 | return false;
66 | }
67 | }
68 |
69 | export default Move;
70 |
--------------------------------------------------------------------------------
/src/interaction/selectmodify.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-underscore-dangle */
2 | import Select from 'ol/interaction/Select';
3 | import { doubleClick } from 'ol/events/condition';
4 | import { Circle, Style, Fill, Stroke } from 'ol/style';
5 | import GeometryCollection from 'ol/geom/GeometryCollection';
6 | import { MultiPoint } from 'ol/geom';
7 |
8 | // Default style on modifying geometries
9 | const selectModifyStyle = new Style({
10 | zIndex: 10000, // Always on top
11 | image: new Circle({
12 | radius: 5,
13 | fill: new Fill({
14 | color: '#05A0FF',
15 | }),
16 | stroke: new Stroke({ color: '#05A0FF', width: 2 }),
17 | }),
18 | stroke: new Stroke({
19 | color: '#05A0FF',
20 | width: 3,
21 | }),
22 | fill: new Fill({
23 | color: 'rgba(255,255,255,0.4)',
24 | }),
25 | geometry: (f) => {
26 | const coordinates = [];
27 | const geometry = f.getGeometry();
28 | let geometries = [geometry];
29 | if (geometry.getType() === 'GeometryCollection') {
30 | geometries = geometry.getGeometriesArrayRecursive();
31 | }
32 |
33 | // At this point geometries doesn't contains any GeometryCollections.
34 | geometries.forEach((geom) => {
35 | let multiGeometries = [geom];
36 | if (geom.getType() === 'MultiLineString') {
37 | multiGeometries = geom.getLineStrings();
38 | } else if (geom.getType() === 'MultiPolygon') {
39 | multiGeometries = geom.getPolygons();
40 | } else if (geom.getType() === 'MultiPoint') {
41 | multiGeometries = geom.getPoints();
42 | }
43 | // At this point multiGeometries contains only single geometry.
44 | multiGeometries.forEach((geomm) => {
45 | if (geomm.getType() === 'Polygon') {
46 | geomm.getLinearRings().forEach((ring) => {
47 | coordinates.push(...ring.getCoordinates());
48 | });
49 | } else if (geomm.getType() === 'LineString') {
50 | coordinates.push(...geomm.getCoordinates());
51 | } else if (geomm.getType() === 'Point') {
52 | coordinates.push(geomm.getCoordinates());
53 | }
54 | });
55 | });
56 | return new GeometryCollection([
57 | f.getGeometry(),
58 | new MultiPoint(coordinates),
59 | ]);
60 | },
61 | });
62 |
63 | /**
64 | * Select features for modification by a Modify interaction.
65 | *
66 | * Default behavior:
67 | * - Double click on the feature to select one feature.
68 | * - Click on the map to deselect all features.
69 | */
70 | class SelectModify extends Select {
71 | /**
72 | * @param {Options=} options Options.
73 | * @ignore
74 | */
75 | constructor(options) {
76 | super({
77 | condition: doubleClick,
78 | style: selectModifyStyle,
79 | ...options,
80 | });
81 | }
82 |
83 | // We redefine the handle method to avoid propagation of double click to the map.
84 | handleEvent(mapBrowserEvent) {
85 | if (!this.condition_(mapBrowserEvent)) {
86 | return true;
87 | }
88 | const add = this.addCondition_(mapBrowserEvent);
89 | const remove = this.removeCondition_(mapBrowserEvent);
90 | const toggle = this.toggleCondition_(mapBrowserEvent);
91 | const { map } = mapBrowserEvent;
92 | const set = !add && !remove && !toggle;
93 | if (set) {
94 | let isEvtOnSelectableFeature = false;
95 | map.forEachFeatureAtPixel(
96 | mapBrowserEvent.pixel,
97 | (feature, layer) => {
98 | if (this.filter_(feature, layer)) {
99 | isEvtOnSelectableFeature = true;
100 | }
101 | },
102 | {
103 | layerFilter: this.layerFilter_,
104 | hitTolerance: this.hitTolerance_,
105 | },
106 | );
107 |
108 | if (isEvtOnSelectableFeature) {
109 | // if a feature is about to be selected or unselected we stop event propagation.
110 | super.handleEvent(mapBrowserEvent);
111 | return false;
112 | }
113 | }
114 |
115 | return super.handleEvent(mapBrowserEvent);
116 | }
117 | }
118 |
119 | export default SelectModify;
120 |
--------------------------------------------------------------------------------
/src/interaction/selectmove.js:
--------------------------------------------------------------------------------
1 | import Select from 'ol/interaction/Select';
2 | import { Circle, Style, Fill, Stroke } from 'ol/style';
3 | import { singleClick } from 'ol/events/condition';
4 |
5 | // Default style on moving geometries
6 | const selectMoveStyle = new Style({
7 | zIndex: 10000, // Always on top
8 | image: new Circle({
9 | radius: 5,
10 | fill: new Fill({
11 | color: '#05A0FF',
12 | }),
13 | stroke: new Stroke({ color: '#05A0FF', width: 2 }),
14 | }),
15 | stroke: new Stroke({
16 | color: '#05A0FF',
17 | width: 3,
18 | }),
19 | fill: new Fill({
20 | color: 'rgba(255,255,255,0.4)',
21 | }),
22 | });
23 |
24 | /**
25 | * Select features for modification by a Move interaction.
26 | *
27 | * Default behavior:
28 | * - Single click on the feature to select one feature.
29 | */
30 | class SelectMove extends Select {
31 | /**
32 | * @param {Options=} options Options.
33 | * @ignore
34 | */
35 | constructor(options) {
36 | super({
37 | condition: singleClick,
38 | style: selectMoveStyle,
39 | ...options,
40 | });
41 | }
42 | }
43 |
44 | export default SelectMove;
45 |
--------------------------------------------------------------------------------
/src/service/index.js:
--------------------------------------------------------------------------------
1 | export { default as LocalStorage } from './local-storage';
2 | export { default as Storage } from './storage';
3 |
--------------------------------------------------------------------------------
/src/service/local-storage.js:
--------------------------------------------------------------------------------
1 | import Storage from './storage';
2 |
3 | /**
4 | * OLE LocalStorage.
5 | * Saves control properties to the browser's localStorage.
6 | * @alias ole.service.LocalStorage
7 | */
8 | export default class LocalStorage extends Storage {
9 | /**
10 | * @inheritdoc
11 | */
12 | storeProperties(controlName, properties) {
13 | const props = super.storeProperties(controlName, properties);
14 | window.localStorage.setItem(controlName, JSON.stringify(props));
15 | }
16 |
17 | /**
18 | * @inheritdoc
19 | */
20 | restoreProperties() {
21 | for (let i = 0; i < this.controls.length; i += 1) {
22 | const controlName = this.controls[i].getProperties().title;
23 | const props = window.localStorage.getItem(controlName);
24 |
25 | if (props) {
26 | this.controls[i].setProperties(JSON.parse(props), true);
27 | }
28 | }
29 | }
30 |
31 | /**
32 | * @inheritdoc
33 | */
34 | storeActiveControls() {
35 | const activeControlNames = super.storeActiveControls();
36 | window.localStorage.setItem('active', JSON.stringify(activeControlNames));
37 | }
38 |
39 | /**
40 | * @inheritdoc
41 | */
42 | restoreActiveControls() {
43 | let activeControlNames = window.localStorage.getItem('active');
44 | activeControlNames = activeControlNames
45 | ? JSON.parse(activeControlNames)
46 | : [];
47 |
48 | if (!activeControlNames.length) {
49 | return;
50 | }
51 |
52 | for (let i = 0; i < this.controls.length; i += 1) {
53 | const controlName = this.controls[i].getProperties().title;
54 |
55 | if (activeControlNames.indexOf(controlName) > -1) {
56 | this.controls[i].activate();
57 | } else {
58 | this.controls[i].deactivate();
59 | }
60 | }
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/service/service.js:
--------------------------------------------------------------------------------
1 | /**
2 | * OLE service base class.
3 | * @alias ole.Service
4 | */
5 | export default class Service {
6 | constructor() {
7 | this.active = false;
8 |
9 | /**
10 | * @type {ole.Editor}
11 | * @private
12 | */
13 | this.editor = null;
14 |
15 | /**
16 | * @type {ol.Map}
17 | * @private
18 | */
19 | this.map = null;
20 | }
21 |
22 | /**
23 | * Activate the service.
24 | * @priavte
25 | */
26 | activate() {
27 | this.active = true;
28 | }
29 |
30 | /**
31 | * Deactivate the service.
32 | * @priavte
33 | */
34 | deactivate() {
35 | this.active = false;
36 | }
37 |
38 | /**
39 | * Set the service's editor instance.
40 | * @param {ole.Editor} editor Editor instance.
41 | */
42 | setEditor(editor) {
43 | this.editor = editor;
44 | }
45 |
46 | /**
47 | * Set the service's map.
48 | * @param {ol.Map} map Map object.
49 | */
50 | setMap(map) {
51 | this.map = map;
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/service/storage.js:
--------------------------------------------------------------------------------
1 | import Service from './service';
2 |
3 | /**
4 | * OLE storage service.
5 | * Base class for storage services,
6 | * such as LocalStorage, PermalinkStorage, CookieStorage.
7 | * @alias ole.service.Storage
8 | */
9 | export default class Storage extends Service {
10 | /**
11 | * Saves control properties.
12 | * @param {object} [options] Service options
13 | * @param {array.} [controls] List of controls.
14 | * If undefined, all controls of the editor are used.
15 | */
16 | constructor(optOptions) {
17 | const options = optOptions || {};
18 | super();
19 |
20 | /**
21 | * List of service controls
22 | * @type {array.}
23 | * @private
24 | */
25 | this.controls = options.controls;
26 |
27 | /**
28 | * List of properties keys to ignore.
29 | * @type {array.}
30 | */
31 | this.ignoreKeys = ['title', 'image', 'className'];
32 | }
33 |
34 | /**
35 | * @inheritdoc
36 | */
37 | activate() {
38 | super.activate();
39 | this.controls = this.controls || this.editor.getControls().getArray();
40 | this.restoreProperties();
41 | this.restoreActiveControls();
42 |
43 | this.controls.forEach((control) => {
44 | control.addEventListener('propertychange', (evt) => {
45 | this.storeProperties(
46 | evt.detail.control.getProperties().title,
47 | evt.detail.properties,
48 | );
49 | });
50 |
51 | control.addEventListener('change:active', () => {
52 | this.storeActiveControls();
53 | });
54 | });
55 | }
56 |
57 | /**
58 | * @inheritdoc
59 | */
60 | deactivate() {
61 | super.deactivate();
62 |
63 | this.controls.forEach((control) => {
64 | control.removeEventListener('propertychange');
65 | });
66 | }
67 |
68 | /**
69 | * Store control properties.
70 | * @param {string} controlName Name of the control.
71 | * @param {object} properties Control properties.
72 | */
73 | storeProperties(controlName, properties) {
74 | const storageProps = {};
75 | const propKeys = Object.keys(properties);
76 |
77 | for (let i = 0; i < propKeys.length; i += 1) {
78 | const key = propKeys[i];
79 | if (
80 | this.ignoreKeys.indexOf(key) === -1 &&
81 | !(properties[key] instanceof Object)
82 | ) {
83 | storageProps[key] = properties[key];
84 | }
85 | }
86 |
87 | return storageProps;
88 | }
89 |
90 | /**
91 | * Restore the control properties.
92 | */
93 | // eslint-disable-next-line class-methods-use-this
94 | restoreProperties() {
95 | // to be implemented by child class
96 | }
97 |
98 | /**
99 | * Store the active state of controls.
100 | */
101 | storeActiveControls() {
102 | const activeControls = this.editor.getActiveControls();
103 | return activeControls.getArray().map((c) => c.getProperties().title);
104 | }
105 |
106 | /**
107 | * Restore the active state of the controls.
108 | */
109 | // eslint-disable-next-line class-methods-use-this
110 | restoreActiveControls() {
111 | // to be implemented by child class
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/style/ole.css:
--------------------------------------------------------------------------------
1 | #ole-toolbar {
2 | position: absolute;
3 | right: 20px;
4 | top: 20px;
5 | }
6 |
7 | /* shadow */
8 | #ole-toolbar button.ole-control,
9 | .ole-dialog {
10 | box-shadow: 0 3px 3px 0 rgb(0 0 0 / 20%);
11 | }
12 |
13 | /* buttons */
14 | #ole-toolbar button.ole-control {
15 | background: #fafafa;
16 | border: 0;
17 | color: #999;
18 | cursor: pointer;
19 | font-size: 14px;
20 | line-height: 36px;
21 | height: 45px;
22 | transition: all 0.3s ease-out;
23 | padding: 5px;
24 | }
25 |
26 | #ole-toolbar button.ole-control:first-child {
27 | border-radius: 4px 0 0 4px;
28 | }
29 |
30 | #ole-toolbar button.ole-control:last-child {
31 | border-radius: 0 4px 4px 0;
32 | }
33 |
34 | #ole-toolbar button.ole-control:hover {
35 | color: #5c5c5c;
36 | }
37 |
38 | #ole-toolbar button.ole-control:focus {
39 | outline: 0;
40 | }
41 |
42 | #ole-toolbar button.ole-control.active {
43 | box-shadow: 0 4px 4px 0 rgb(0 0 0 / 30%);
44 | color: #5c5c5c;
45 | filter: brightness(90%);
46 | }
47 |
48 | #ole-toolbar button.ole-control img {
49 | height: 35px;
50 | }
51 |
52 | /* dialog */
53 | .ole-dialog {
54 | background: #fafafa;
55 | border-radius: 4px;
56 | right: 20px;
57 | padding: 10px;
58 | position: absolute;
59 | text-align: left;
60 | top: 75px;
61 | width: 330px;
62 | z-index: 2;
63 | }
64 |
65 | /* font */
66 | #ole-toolbar,
67 | .ole-dialog {
68 | font-family: Arial, sans-serif;
69 | font-size: 14px;
70 | }
71 |
72 | #width-input {
73 | width: 50px;
74 | }
75 |
--------------------------------------------------------------------------------
/style/style.css:
--------------------------------------------------------------------------------
1 | html {
2 | margin: 0;
3 | padding: 0;
4 | height: 100%;
5 | }
6 |
7 | body {
8 | margin: 0;
9 | padding: 0;
10 | height: 100%;
11 | }
12 |
13 | a {
14 | text-decoration: none;
15 | color: #61849c;
16 | }
17 |
18 | a:hover {
19 | text-decoration: underline;
20 | }
21 |
22 | a.active,
23 | a.visited {
24 | font-weight: bold;
25 | color: #61849c;
26 | }
27 |
28 | #header {
29 | border-bottom: 2px solid #61849c;
30 | color: #61849c;
31 | height: 20px;
32 | padding: 20px;
33 | text-align: right;
34 | }
35 |
36 | #header nav {
37 | display: flex;
38 | justify-content: space-between;
39 | align-items: center;
40 | }
41 |
42 | #links {
43 | display: flex;
44 | gap: 40px;
45 | padding: 0 40px;
46 | }
47 |
48 | #map {
49 | position: relative;
50 | height: 100%;
51 | }
52 |
53 | #app {
54 | font-family: Avenir, Helvetica, Arial, sans-serif;
55 | -webkit-font-smoothing: antialiased;
56 | -moz-osx-font-smoothing: grayscale;
57 | color: #2c3e50;
58 | min-height: 100%;
59 | overflow: hidden;
60 | position: absolute;
61 | width: 100%;
62 | height: 100%;
63 | }
64 |
65 | #brand {
66 | font-size: 1.5em;
67 | font-weight: bold;
68 | }
69 |
70 | #promo {
71 | position: absolute;
72 | bottom: 50px;
73 | background-color: red;
74 | right: -65px;
75 | transform: rotate(-45deg);
76 | z-index: 1;
77 | }
78 |
79 | #promo-text {
80 | border: 2px solid white;
81 | color: white;
82 | font-size: 14px;
83 | font-family: sans-serif;
84 | font-weight: bold;
85 | margin: 2px;
86 | padding: 0 70px;
87 | }
88 |
89 | #promo a:hover {
90 | text-decoration: none;
91 | }
92 |
93 | #copyright {
94 | font-family: Arial, sans-serif;
95 | font-size: 12px;
96 | background-color: rgb(255 255 255);
97 | box-shadow: 1px 2px 4px rgb(0 0 0 / 70%);
98 | position: absolute;
99 | display: flex;
100 | gap: 5px;
101 | padding: 5px;
102 | bottom: 0;
103 | left: 0;
104 | right: 0;
105 | }
106 |
--------------------------------------------------------------------------------
/tasks/prepare-package.mjs:
--------------------------------------------------------------------------------
1 | import { readFileSync, writeFileSync } from 'node:fs';
2 | import { dirname, join } from 'node:path';
3 | import { fileURLToPath } from 'node:url';
4 |
5 | const baseDir = dirname(fileURLToPath(import.meta.url));
6 |
7 | function main() {
8 | const pkg = JSON.parse(readFileSync(join(baseDir, '../package.json')));
9 |
10 | // write out simplified package.json
11 | pkg.main = 'index.js';
12 | delete pkg.devDependencies;
13 | delete pkg.scripts;
14 | const data = JSON.stringify(pkg, null, 2);
15 | writeFileSync(join(baseDir, '../build/package.json'), data);
16 |
17 | // copy over license and readme
18 | writeFileSync(
19 | join(baseDir, '../build/LICENSE'),
20 | readFileSync(join(baseDir, '../README.md')),
21 | );
22 |
23 | writeFileSync(
24 | join(baseDir, '../build/README.md'),
25 | readFileSync(join(baseDir, '../README.md')),
26 | );
27 | }
28 |
29 | main();
30 |
--------------------------------------------------------------------------------
/vitest.config.mjs:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | // eslint-disable-next-line import/no-unresolved
3 | import { defineConfig } from 'vitest/config';
4 |
5 | export default defineConfig({
6 | test: {
7 | environment: 'happy-dom',
8 | },
9 | });
10 |
--------------------------------------------------------------------------------