From f8b0ced57d64dc5910c61f30e1f57f3686abf739 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 2 Aug 2024 12:00:48 +0200 Subject: [PATCH 1/3] Allow editing hotspot area point coordinates as numbers REDMINE-20673 --- .../editor/EditAreaDialogView/reducer-spec.js | 502 +++++++++++++++++- .../EditAreaDialogView/DraggableEditorView.js | 85 ++- .../DraggableEditorView.module.css | 35 +- .../editor/EditAreaDialogView/reducer.js | 233 +++++++- 4 files changed, 813 insertions(+), 42 deletions(-) diff --git a/entry_types/scrolled/package/spec/contentElements/hotspots/editor/EditAreaDialogView/reducer-spec.js b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/EditAreaDialogView/reducer-spec.js index f6c3639b72..3abb74d49c 100644 --- a/entry_types/scrolled/package/spec/contentElements/hotspots/editor/EditAreaDialogView/reducer-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/hotspots/editor/EditAreaDialogView/reducer-spec.js @@ -4,13 +4,17 @@ import { handles, SET_MODE, DRAG, + CLICK_HANDLE, DRAG_HANDLE, DRAG_HANDLE_STOP, DOUBLE_CLICK_HANDLE, MOUSE_MOVE, DRAG_POTENTIAL_POINT, DRAG_POTENTIAL_POINT_STOP, - DRAG_INDICATOR + CLICK_INDICATOR, + DRAG_INDICATOR, + UPDATE_SELECTION_POSITION, + BLUR_SELECTION_POSITION } from 'contentElements/hotspots/editor/EditAreaDialogView/reducer'; const initialState = { @@ -90,6 +94,18 @@ describe('reducer', () => { points: [[10, 10], [60, 10], [60, 50], [10, 50]] }); }); + + it('resets selection', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20], [50, 10], [50, 50], [10, 50]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 2}); + state = reducer(state, {type: SET_MODE, value: 'rect'}); + + expect(state.selection).toBeNull(); + }); }); describe('DRAG', () => { @@ -139,6 +155,75 @@ describe('reducer', () => { }); }); + describe('CLICK_HANDLE', () => { + describe('in polygon mode', () => { + it('sets selection for handle', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20.123], [50, 10], [50, 50], [10, 50]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 1}); + + expect(state.selection).toEqual({ + type: 'handle', + index: 1, + position: [20, 20.1] + }); + }); + }); + + describe('in rect mode', () => { + it('sets selection for top mid point handle', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 1}); + + expect(state.selection).toEqual({ + type: 'handle', + axis: 'y', + index: 1, + position: [15, 20] + }); + }); + + it('sets selection for left mid point handle', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 7}); + + expect(state.selection).toEqual({ + type: 'handle', + axis: 'x', + index: 7, + position: [10, 30] + }); + }); + + it('sets selection for corner handle', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20.543, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 2}); + + expect(state.selection).toEqual({ + type: 'handle', + axis: 'both', + index: 2, + position: [20.5, 20] + }); + }); + }); + }); + describe('DRAG_HANDLE', () => { describe('in polygon mode', () => { it('updates points', () => { @@ -177,6 +262,21 @@ describe('reducer', () => { expect(state.indicatorPosition).toEqual([15, 23]); }); + + it('sets selection for handle', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20], [50, 10], [50, 50], [10, 50]] + }; + state = reducer(state, {type: DRAG_HANDLE, index: 1, cursor: [30.42, 25]}); + + expect(state.selection).toEqual({ + type: 'handle', + index: 1, + position: [30.4, 25] + }); + }); }); describe('in rect mode', () => { @@ -193,6 +293,19 @@ describe('reducer', () => { ); }); + it('ignores cross-axis coordinate of mid point handle', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: DRAG_HANDLE, index: 1, cursor: [25, 10]}); + + expect(state.points).toEqual( + [[10, 10], [20, 10], [20, 40], [10, 40]] + ); + }); + it('resizes rect via corner handle', () => { let state = { ...initialState, @@ -244,6 +357,66 @@ describe('reducer', () => { expect(state.indicatorPosition).toEqual([15, 30]); }); + + it('sets selection for top mid point handle', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: DRAG_HANDLE, index: 1, cursor: [14, 10]}); + + expect(state.selection).toEqual({ + type: 'handle', + axis: 'y', + index: 1, + position: [15, 10] + }); + }); + + it('sets selection for left mid point handle', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: DRAG_HANDLE, index: 7, cursor: [8, 28]}); + + expect(state.selection).toEqual({ + type: 'handle', + axis: 'x', + index: 7, + position: [8, 30] + }); + }); + + it('sets selection for corner handle', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: DRAG_HANDLE, index: 2, cursor: [22.45, 19]}); + + expect(state.selection).toEqual({ + type: 'handle', + axis: 'both', + index: 2, + position: [22.5, 19] + }); + }); + + it('changes selected handle when moving top left corner handle past bottom right boundary', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: DRAG_HANDLE, index: 0, cursor: [10, 20]}); + state = reducer(state, {type: DRAG_HANDLE, index: 0, cursor: [30, 50]}); + + expect(state.selection.index).toEqual(4); + }); }); }); @@ -308,6 +481,18 @@ describe('reducer', () => { expect(state.indicatorPosition).toEqual([14, 24]); }); + + it('resets selection', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20], [50, 10], [50, 50], [10, 50]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 1}); + state = reducer(state, {type: DOUBLE_CLICK_HANDLE, index: 1}); + + expect(state.selection).toBeNull(); + }); }); describe('MOUSE_MOVE', () => { @@ -358,6 +543,20 @@ describe('reducer', () => { expect(state.potentialPoint).toEqual([20, 10]); }); + + it('sets selection to potential point', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 20], [50, 10], [50, 50], [10, 50]] + }; + state = reducer(state, {type: DRAG_POTENTIAL_POINT, cursor: [20.1234, 10]}); + + expect(state.selection).toEqual({ + type: 'potentialPoint', + position: [20.1, 10] + }); + }); }); describe('DRAG_POTENTIAL_POINT_STOP', () => { @@ -390,6 +589,40 @@ describe('reducer', () => { expect(state.potentialPoint).toEqual([15, 10]); }); + + it('selected handle of new point', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 20], [50, 10], [50, 50], [10, 50]] + }; + state = reducer(state, {type: MOUSE_MOVE, cursor: [20, 10]}); + state = reducer(state, {type: DRAG_POTENTIAL_POINT, cursor: [20.123, 10]}); + state = reducer(state, {type: DRAG_POTENTIAL_POINT_STOP}); + + expect(state.selection).toEqual({ + type: 'handle', + index: 1, + position: [20.1, 10] + }); + }); + }); + + describe('CLICK_INDICATOR', () => { + it('sets selection', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 10], [20, 50]], + indicatorPosition: [11, 11.123] + }; + state = reducer(state, {type: CLICK_INDICATOR}); + + expect(state.selection).toEqual({ + type: 'indicator', + position: [11, 11.1] + }); + }); }); describe('DRAG_INDICATOR', () => { @@ -416,6 +649,273 @@ describe('reducer', () => { expect(state.indicatorPosition).toEqual([10, 10]); }); + + it('sets selection', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 10], [20, 50]], + indicatorPosition: [11, 11] + }; + state = reducer(state, {type: DRAG_INDICATOR, cursor: [15, 11.123]}); + + expect(state.selection).toEqual({ + type: 'indicator', + position: [15, 11.1] + }); + }); + }); + + describe('UPDATE_SELECTION_POSITION', () => { + it('updates indicator position', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 10], [20, 50]], + indicatorPosition: [11, 11] + }; + state = reducer(state, {type: CLICK_INDICATOR}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [12, 11]}); + + expect(state.indicatorPosition).toEqual([12, 11]); + expect(state.selection.position).toEqual([12, 11]); + }); + + it('allows invalid indicator position in selection', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 10], [20, 50]], + indicatorPosition: [11, 11] + }; + state = reducer(state, {type: CLICK_INDICATOR}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [0, 0]}); + + expect(state.indicatorPosition).toEqual([10, 10]); + expect(state.selection.position).toEqual([0, 0]); + }); + + it('allows invalid handle position in selection', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 10], [20, 50]], + indicatorPosition: [11, 11] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 1}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [-10, 120]}); + + expect(state.points[1]).toEqual([0, 100]); + expect(state.selection.position).toEqual([-10, 120]); + }); + + it('updates polygon handle position', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 10], [20, 50]], + indicatorPosition: [11, 11] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 1}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [12, 11]}); + + expect(state.points[1]).toEqual([12, 11]); + expect(state.selection.position).toEqual([12, 11]); + }); + + it('updates rect corner handle position', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 2}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [22, 22]}); + + expect(state.points).toEqual( + [[10, 22], [22, 22], [22, 40], [10, 40]] + ); + }); + + it('updates rect top mid point handle position', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 1}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [15, 10]}); + + expect(state.points).toEqual( + [[10, 10], [20, 10], [20, 40], [10, 40]] + ); + }); + + it('changes selected handle when moving right mid point handle past left boundary', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 3}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [20, 30]}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [5, 30]}); + + expect(state.selection.index).toEqual(7); + }); + + it('changes selected handle when moving left mid point handle past right boundary', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 7}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [10, 30]}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [25, 30]}); + + expect(state.selection.index).toEqual(3); + }); + + it('changes selected handle when moving top mid point handle past bottom boundary', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 1}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [15, 20]}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [15, 50]}); + + expect(state.selection.index).toEqual(5); + }); + + it('changes selected handle when moving bottom mid point handle past top boundary', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 5}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [15, 40]}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [15, 10]}); + + expect(state.selection.index).toEqual(1); + }); + + it('changes selected handle when moving top right corner handle past left boundary', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 2}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [20, 20]}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [5, 20]}); + + expect(state.selection.index).toEqual(0); + }); + + it('changes selected handle when moving top left corner handle past bottom boundary', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 0}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [10, 20]}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [10, 50]}); + + expect(state.selection.index).toEqual(6); + }); + }); + + describe('BLUR_SELECTION_POSITION', () => { + it('sanitizes indicator position in selection', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 10], [20, 50]], + indicatorPosition: [11, 11] + }; + state = reducer(state, {type: CLICK_INDICATOR}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [0, 0]}); + state = reducer(state, {type: BLUR_SELECTION_POSITION}); + + expect(state.indicatorPosition).toEqual([10, 10]); + expect(state.selection.position).toEqual([10, 10]); + }); + + it('keeps handle selection unchanged', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 10], [20, 10], [20, 50]], + indicatorPosition: [12, 12] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 0}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [10, 11]}); + state = reducer(state, {type: BLUR_SELECTION_POSITION}); + + expect(state.selection.position).toEqual([10, 11]); + }); + + it('sanitizes polygon handle position in selection', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20], [50, 10], [50, 50], [15, 50]], + indicatorPosition: [10, 20] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 0}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [-10, 120]}); + state = reducer(state, {type: BLUR_SELECTION_POSITION}); + + expect(state.selection.position).toEqual([0, 100]); + }); + + it('sanitizes rect handle position in selection', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]], + indicatorPosition: [10, 20] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 2}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [120, -10]}); + state = reducer(state, {type: BLUR_SELECTION_POSITION}); + + expect(state.selection.position).toEqual([100, 0]); + }); + + it('moves indicator inside area', () => { + let state = { + ...initialState, + mode: 'polygon', + points: [[10, 20], [20, 20], [50, 10], [50, 50], [15, 50]], + indicatorPosition: [10, 20] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 0}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [15, 20]}); + state = reducer(state, {type: BLUR_SELECTION_POSITION}); + + expect(state.indicatorPosition).toEqual([15, 20]); + }); + + it('resets start points for next drag handle', () => { + let state = { + ...initialState, + mode: 'rect', + points: [[10, 20], [20, 20], [20, 40], [10, 40]], + indicatorPosition: [10, 20] + }; + state = reducer(state, {type: CLICK_HANDLE, index: 3}); + state = reducer(state, {type: UPDATE_SELECTION_POSITION, position: [19, 30]}); + state = reducer(state, {type: BLUR_SELECTION_POSITION}); + state = reducer(state, {type: DRAG_HANDLE, index: 7, cursor: [11, 30]}); + + expect(state.points).toEqual([[11, 20], [19, 20], [19, 40], [11, 40]]); + }); }); }); diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/DraggableEditorView.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/DraggableEditorView.js index ba7a16d636..a02c75f414 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/DraggableEditorView.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/DraggableEditorView.js @@ -14,13 +14,17 @@ import { handles, SET_MODE, DRAG, + CLICK_HANDLE, DRAG_HANDLE, DRAG_HANDLE_STOP, DOUBLE_CLICK_HANDLE, MOUSE_MOVE, DRAG_POTENTIAL_POINT, DRAG_POTENTIAL_POINT_STOP, - DRAG_INDICATOR + CLICK_INDICATOR, + DRAG_INDICATOR, + UPDATE_SELECTION_POSITION, + BLUR_SELECTION_POSITION } from './reducer'; import styles from './DraggableEditorView.module.css'; @@ -82,7 +86,7 @@ function DraggableEditor({ }); const { - mode, points, potentialPoint, indicatorPosition + mode, points, potentialPoint, indicatorPosition, selection } = state; useEffect( @@ -113,7 +117,17 @@ function DraggableEditor({ return (
- +
+ + dispatch(l({ + type: UPDATE_SELECTION_POSITION, + position + }))} + onBlur={() => dispatch(l({ + type: BLUR_SELECTION_POSITION + }))} /> +
dispatch({ + type: CLICK_HANDLE, + index + })} onDoubleClick={event => dispatch({ type: DOUBLE_CLICK_HANDLE, index @@ -165,6 +184,7 @@ function DraggableEditor({ {potentialPoint && dispatch({ type: DRAG_POTENTIAL_POINT, cursor: clientToPercent(event) @@ -174,7 +194,11 @@ function DraggableEditor({ })} />} dispatch({ + type: CLICK_INDICATOR + })} onDrag={event => dispatch({ type: DRAG_INDICATOR, cursor: clientToPercent(event) @@ -192,7 +216,7 @@ const modeIcons = { function ModeButtons({mode, dispatch}) { return ( -
+
{['rect', 'polygon'].map(availableMode => + ); +} + function Coordinates({selection, onChange, onBlur}) { if (!selection) { return null; @@ -304,6 +320,3 @@ function CoordinateInput({disabled, label, value, onChange, onBlur}) {
); } -function l(v) { - return v; -} diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/DraggableEditorView.module.css b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/DraggableEditorView.module.css index 0e58f0bc47..b91ca58c82 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/DraggableEditorView.module.css +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/DraggableEditorView.module.css @@ -17,12 +17,23 @@ justify-content: space-between; align-items: center; margin: 10px 0; + gap: 15px; +} + +.buttons button { + white-space: nowrap; } .coordinates, .coordinates label { display: flex; align-items: center; + margin: 0; +} + +.coordinates { + flex-wrap: wrap; + justify-content: flex-end; } .coordinates { @@ -37,6 +48,11 @@ background-color: var(--ui-on-surface-color-lightest); } +.modeButtons { + flex: 1; + white-space: nowrap; +} + .modeButtons button:first-child { border-top-right-radius: 0; border-bottom-right-radius: 0; @@ -52,7 +68,7 @@ background-color: var(--ui-selection-color-light); } -.modeButtons button img { +.buttons img { vertical-align: middle; margin-right: 6px; } diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/images/center.svg b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/images/center.svg new file mode 100644 index 0000000000..4462feb583 --- /dev/null +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/images/center.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/reducer.js b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/reducer.js index 085aa83b77..da085f220b 100644 --- a/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/reducer.js +++ b/entry_types/scrolled/package/src/contentElements/hotspots/editor/EditAreaDialogView/reducer.js @@ -9,6 +9,7 @@ export const DRAG_POTENTIAL_POINT = 'DRAG_POTENTIAL_POINT'; export const DRAG_POTENTIAL_POINT_STOP = 'DRAG_POTENTIAL_POINT_STOP'; export const CLICK_INDICATOR = 'CLICK_INDICATOR'; export const DRAG_INDICATOR = 'DRAG_INDICATOR'; +export const CENTER_INDICATOR = 'CENTER_INDICATOR'; export const UPDATE_SELECTION_POSITION = 'UPDATE_SELECTION_POSITION'; export const BLUR_SELECTION_POSITION = 'BLUR_SELECTION_POSITION'; @@ -173,6 +174,11 @@ export function reducer(state, action) { position: round(indicatorPosition) } } + case CENTER_INDICATOR: + return { + ...state, + indicatorPosition: polygonCentroid(state.points) + }; case UPDATE_SELECTION_POSITION: if (state.selection?.type === 'indicator') { return { @@ -458,6 +464,43 @@ function closestPointOnPolygon(polygon, c, maxDistance = 5) { return closest; } +function polygonCentroid(points) { + let centroidX = 0; + let centroidY = 0; + let signedArea = 0; + let x0 = 0; + let y0 = 0; + let x1 = 0; + let y1 = 0; + let a = 0; + + for (let i = 0; i < points.length - 1; i++) { + x0 = points[i][0]; + y0 = points[i][1]; + x1 = points[i + 1][0]; + y1 = points[i + 1][1]; + a = x0 * y1 - x1 * y0; + signedArea += a; + centroidX += (x0 + x1) * a; + centroidY += (y0 + y1) * a; + } + + x0 = points[points.length - 1][0]; + y0 = points[points.length - 1][1]; + x1 = points[0][0]; + y1 = points[0][1]; + a = x0 * y1 - x1 * y0; + signedArea += a; + centroidX += (x0 + x1) * a; + centroidY += (y0 + y1) * a; + + signedArea *= 0.5; + centroidX /= (6 * signedArea); + centroidY /= (6 * signedArea); + + return [centroidX, centroidY]; +} + function round(point) { return point.map(coord => Math.round(coord * 10) / 10); }