diff --git a/demo/src/components/Samples/Multiple/index.js b/demo/src/components/Samples/Multiple/index.js index 61f412f..a823b60 100644 --- a/demo/src/components/Samples/Multiple/index.js +++ b/demo/src/components/Samples/Multiple/index.js @@ -3,7 +3,8 @@ import Annotation from '../../../../../src' import { PointSelector, RectangleSelector, - OvalSelector + OvalSelector, + PolygonSelector } from '../../../../../src/selectors' import Button from '../../Button' @@ -65,6 +66,12 @@ export default class Multiple extends Component { > {OvalSelector.TYPE} + { + window.addEventListener("resize", this.forceUpdateComponent); + } + + componentWillUnmount = () => { + window.removeEventListener("resize", this.forceUpdateComponent); + } + + forceUpdateComponent = () => { + this.forceUpdate(); + } + + componentDidUpdate = prevProps => { + if (prevProps.imageZoomAmount !== this.props.imageZoomAmount) { + this.forceUpdateComponent(); + } + } + setInnerRef = (el) => { this.container = el this.props.relativeMousePos.innerRef(el) @@ -134,7 +164,17 @@ export default compose( onMouseUp = (e) => this.callSelectorMethod('onMouseUp', e) onMouseDown = (e) => this.callSelectorMethod('onMouseDown', e) onMouseMove = (e) => this.callSelectorMethod('onMouseMove', e) - onClick = (e) => this.callSelectorMethod('onClick', e) + onClick = (e) => { + const { onClickCheckFunc } = this.props; + + if (!onClickCheckFunc || onClickCheckFunc(e)) { + return this.callSelectorMethod('onClick', e) + } + return; + } + onSelectionComplete = () => this.callSelectorMethod('onSelectionComplete') + onSelectionClear = () => this.callSelectorMethod('onSelectionClear') + onSelectionUndo = () => this.callSelectorMethod('onSelectionUndo') onSubmit = () => { this.props.onSubmit(this.props.value) @@ -187,7 +227,8 @@ export default compose( renderContent, renderSelector, renderEditor, - renderOverlay + renderOverlay, + renderPolygonControls } = props const topAnnotationAtMouse = this.getTopAnnotationAt( @@ -240,13 +281,14 @@ export default compose( }) )} {props.annotations.map(annotation => ( - this.shouldAnnotationBeActive(annotation, topAnnotationAtMouse) - && ( + /* this.shouldAnnotationBeActive(annotation, topAnnotationAtMouse) + && ( */ renderContent({ key: annotation.data.id, - annotation: annotation + annotation: annotation, + imageZoomAmount: props.imageZoomAmount }) - ) + // ) ))} {!props.disableEditor && props.value @@ -256,7 +298,22 @@ export default compose( renderEditor({ annotation: props.value, onChange: props.onChange, - onSubmit: this.onSubmit + onSubmit: this.onSubmit, + imageZoomAmount: props.imageZoomAmount + }) + ) + } + {props.value + && props.value.geometry + && (props.value.geometry.type === PolygonSelector.TYPE) + && (!props.value.selection || !props.value.selection.showEditor) + && ( + renderPolygonControls({ + annotation: props.value, + onSelectionComplete: this.onSelectionComplete, + onSelectionClear: this.onSelectionClear, + onSelectionUndo: this.onSelectionUndo, + imageZoomAmount: props.imageZoomAmount }) ) } diff --git a/src/components/Content/index.js b/src/components/Content/index.js index fbda50a..8d742c6 100644 --- a/src/components/Content/index.js +++ b/src/components/Content/index.js @@ -1,5 +1,7 @@ import React from 'react' import styled from 'styled-components' +import { getHorizontallyCentralPoint, getVerticallyLowestPoint } from '../../utils/pointsUtils' +import { PolygonSelector } from '../../selectors' const Container = styled.div` background: white; @@ -10,26 +12,37 @@ const Container = styled.div` 0px 3px 1px -2px rgba(0, 0, 0, 0.12); padding: 8px 16px; margin-top: 8px; - margin-left: 8px; + margin-left: -50%; + margin-right: 50%; + color: #363636!important; ` function Content (props) { const { geometry } = props.annotation if (!geometry) return null + const zoomBetweenZeroAndOne = Math.abs(((props.imageZoomAmount - 1) / 4) - 1); + return ( - - {props.annotation.data && props.annotation.data.text} - + + {props.annotation.data && props.annotation.data.age} + {' - '} + {props.annotation.data && props.annotation.data.renovationType} + + ) } diff --git a/src/components/Editor/index.js b/src/components/Editor/index.js index bac865b..2501013 100644 --- a/src/components/Editor/index.js +++ b/src/components/Editor/index.js @@ -1,6 +1,9 @@ import React from 'react' import styled, { keyframes } from 'styled-components' import TextEditor from '../TextEditor' +import RadioButtonEditor from '../RadioButtonEditor' +import { getHorizontallyCentralPoint, getVerticallyLowestPoint } from '../../utils/pointsUtils' +import { PolygonSelector } from '../../selectors' const fadeInScale = keyframes` from { @@ -23,9 +26,10 @@ const Container = styled.div` 0px 3px 1px -2px rgba(0, 0, 0, 0.12); margin-top: 16px; transform-origin: top left; - animation: ${fadeInScale} 0.31s cubic-bezier(0.175, 0.885, 0.32, 1.275); overflow: hidden; + margin-left: -50%; + margin-right: 50% ` function Editor (props) { @@ -33,27 +37,54 @@ function Editor (props) { if (!geometry) return null return ( - - props.onChange({ - ...props.annotation, - data: { - ...props.annotation.data, - text: e.target.value - } - })} - onSubmit={props.onSubmit} - value={props.annotation.data && props.annotation.data.text} - /> - + + {(geometry.type === PolygonSelector.TYPE) && + props.onChange({ + ...props.annotation, + data: { + ...props.annotation.data, + age: e.target.value + } + })} + onChangeRenovationType={e => props.onChange({ + ...props.annotation, + data: { + ...props.annotation.data, + renovationType: e.target.value + } + })} + onSubmit={props.onSubmit} + ageValue={props.annotation.data && props.annotation.data.age} + renovationTypeValue={props.annotation.data && props.annotation.data.renovationType} + imageZoomAmount={props.imageZoomAmount} + /> + } + {(geometry.type !== PolygonSelector.TYPE) && + props.onChange({ + ...props.annotation, + data: { + ...props.annotation.data, + text: e.target.value + } + })} + onSubmit={props.onSubmit} + value={props.annotation.data && props.annotation.data.text} + /> + } + + ) } diff --git a/src/components/Polygon/index.css b/src/components/Polygon/index.css new file mode 100644 index 0000000..9dc0a47 --- /dev/null +++ b/src/components/Polygon/index.css @@ -0,0 +1,13 @@ +.Polygon-LineTo { + box-shadow: 0px 1px 1px 0 white; + box-sizing: border-box; + transition: box-shadow 0.21s ease-in-out; + z-index: -1; +} + +.Polygon-LineToActive { + box-shadow: 0px 1px 1px 0 yellow; + box-sizing: border-box; + transition: box-shadow 0.21s ease-in-out; + z-index: -1; +} diff --git a/src/components/Polygon/index.js b/src/components/Polygon/index.js new file mode 100644 index 0000000..b83c23e --- /dev/null +++ b/src/components/Polygon/index.js @@ -0,0 +1,87 @@ +import React from 'react' +import LineTo from 'react-lineto' +import styled from 'styled-components' +import './index.css' + +const PointDot = styled.div` + background: white; + border-radius: 1px; + width: 2px; + height: 2px; + margin-left: -1px; + margin-top: -1px; + position: absolute; +` + +function edgesFromPoints(points) { + if (!points || points.length < 3) return []; + + const edges = [] + for (let i = 0; i < points.length; ++i) { + if (i + 1 === points.length) { + edges.push(Math.hypot(points[0].x-points[i].x, points[0].y-points[i].y)) + } else { + edges.push(Math.hypot(points[i + 1].x-points[i].x, points[i + 1].y-points[i].y)) + } + } + + return edges; +} + +function Polygon (props) { + const { geometry } = props.annotation + if (!geometry || !geometry.points || geometry.points.length === 0) return null + + return ( +
+ {(geometry.points.length >= 3) && geometry.points.map((item,i) => { // Iterate over points to create the edge lines + let prevItem + if (i === 0) { // First point (links from last to first) + prevItem = geometry.points[geometry.points.length - 1] + } else { + prevItem = geometry.points[i - 1] + } + return ( + // Note that each LineTo element must have a unique key (unique relative to the connected points) + + ) + })} + {geometry.points.map((item,i) => { // Iterate over points to points + return ( + // Note that each LineTo element must have a unique key (unique relative to the point) + + ) + })} +
+ ) +} + +Polygon.defaultProps = { + className: '', + style: {} +} + +export default Polygon diff --git a/src/components/PolygonControls/index.js b/src/components/PolygonControls/index.js new file mode 100644 index 0000000..8ae6bc8 --- /dev/null +++ b/src/components/PolygonControls/index.js @@ -0,0 +1,103 @@ +import React from 'react' +import styled, { keyframes } from 'styled-components' +import { getHorizontallyCentralPoint, getVerticallyLowestPoint } from '../../utils/pointsUtils' + +const fadeInScale = keyframes` + from { + opacity: 0; + transform: scale(0); + } + + to { + opacity: 1; + transform: scale(1); + } +` + +const Container = styled.div` + background: white; + border-radius: 2px; + box-shadow: + 0px 1px 5px 0px rgba(0, 0, 0, 0.2), + 0px 2px 2px 0px rgba(0, 0, 0, 0.14), + 0px 3px 1px -2px rgba(0, 0, 0, 0.12); + transform-origin: top left; + + animation: ${fadeInScale} 0.31s cubic-bezier(0.175, 0.885, 0.32, 1.275); + overflow: hidden; +` +// margin-left: -50%; +// margin-right: 50% +//` + +const Button = styled.div` + background: whitesmoke; + border: 0; + box-sizing: border-box; + color: #363636; + cursor: pointer; + font-size: 1rem; + margin: 0; + outline: 0; + padding: 8px 16px; + text-align: center; + text-shadow: 0 1px 0 rgba(0,0,0,0.1); + width: 100%; + + transition: background 0.21s ease-in-out; + + &:focus, &:hover { + background: #eeeeee; + } +` + +function PolygonControls (props) { + const { geometry } = props.annotation + // Only show polygon controls if there are at least three points set + if (!geometry || !geometry.points || geometry.points.length === 0) return null + + const zoomBetweenZeroAndOne = Math.abs(((props.imageZoomAmount - 1) / 4) - 1); + + const fontSize = ((1 / 5) + (zoomBetweenZeroAndOne * (4 / 5))); + const paddingHorizontal = (((1 / 5) * 8) + ((4 / 5) * 8 * zoomBetweenZeroAndOne)); + const paddingVertical = (((1 / 5) * 16) + ((4 / 5) * 16 * (zoomBetweenZeroAndOne))); + + return ( +
+ + {(geometry.points.length >= 2) && + + } + + {(geometry.points.length >= 3) && + + } + +
+ ) +} + +PolygonControls.defaultProps = { + className: '', + style: {} +} + +export default PolygonControls diff --git a/src/components/RadioButtonEditor/index.js b/src/components/RadioButtonEditor/index.js new file mode 100644 index 0000000..feb01c7 --- /dev/null +++ b/src/components/RadioButtonEditor/index.js @@ -0,0 +1,86 @@ +import React from 'react' +import styled, { keyframes } from 'styled-components' + +const Inner = styled.div` + padding: 8px 16px; + color: #363636!important; +` + +const Button = styled.div` + background: whitesmoke; + border: 0; + box-sizing: border-box; + color: #363636; + cursor: pointer; + font-size: 1rem; + margin: 0; + outline: 0; + padding: 8px 16px; + text-align: center; + text-shadow: 0 1px 0 rgba(0,0,0,0.1); + width: 100%; + + transition: background 0.21s ease-in-out; + + &:focus, &:hover { + background: #eeeeee; + } +` + +function RadioButtonEditor (props) { + const zoomBetweenZeroAndOne = Math.abs(((props.imageZoomAmount - 1) / 4) - 1); + + const fontSize = ((1 / 5) + (zoomBetweenZeroAndOne * (4 / 5))); + + return ( + + +
- Coffee Age -
+
+ +
+
+ +
+ +
- Renovation Type -
+
+ +
+
+ +
+
+ {(props.ageValue && props.ageValue.length > 0 && props.renovationTypeValue && props.renovationTypeValue.length > 0) && + + } +
+ ) +} + +export default RadioButtonEditor diff --git a/src/components/defaultProps.js b/src/components/defaultProps.js index 0cf81c9..d461f86 100644 --- a/src/components/defaultProps.js +++ b/src/components/defaultProps.js @@ -2,16 +2,19 @@ import React from 'react' import Point from './Point' import Editor from './Editor' +import PolygonControls from './PolygonControls' import FancyRectangle from './FancyRectangle' import Rectangle from './Rectangle' import Oval from './Oval' +import Polygon from './Polygon' import Content from './Content' import Overlay from './Overlay' import { RectangleSelector, PointSelector, - OvalSelector + OvalSelector, + PolygonSelector } from '../selectors' export default { @@ -22,12 +25,14 @@ export default { selectors: [ RectangleSelector, PointSelector, - OvalSelector + OvalSelector, + PolygonSelector ], disableAnnotation: false, disableSelector: false, disableEditor: false, disableOverlay: false, + imageZoomAmount: 1, activeAnnotationComparator: (a, b) => a === b, renderSelector: ({ annotation }) => { switch (annotation.geometry.type) { @@ -49,15 +54,31 @@ export default { annotation={annotation} /> ) + case PolygonSelector.TYPE: + return ( + + ) default: return null } }, - renderEditor: ({ annotation, onChange, onSubmit }) => ( + renderEditor: ({ annotation, onChange, onSubmit, imageZoomAmount }) => ( + ), + renderPolygonControls: ({ annotation, onSelectionComplete, onSelectionClear, onSelectionUndo, imageZoomAmount }) => ( + ), renderHighlight: ({ key, annotation, active }) => { @@ -86,14 +107,23 @@ export default { active={active} /> ) + case PolygonSelector.TYPE: + return ( + + ) default: return null } }, - renderContent: ({ key, annotation }) => ( + renderContent: ({ key, annotation, imageZoomAmount }) => ( ), renderOverlay: ({ type, annotation }) => { @@ -104,6 +134,12 @@ export default { Click to Annotate ) + case PolygonSelector.TYPE: + return ( + + Click to Add Points to Annotation + + ) default: return ( diff --git a/src/hocs/PolygonSelector.js b/src/hocs/PolygonSelector.js new file mode 100644 index 0000000..a3c5702 --- /dev/null +++ b/src/hocs/PolygonSelector.js @@ -0,0 +1,134 @@ +var PolygonLookup = require('polygon-lookup') +import { polygon } from 'polygon-tools' + +import { getHorizontallyCentralPoint, getVerticallyLowestPoint } from '../utils/pointsUtils' + +const getCoordPercentage = (e) => ({ + x: e.nativeEvent.offsetX / e.currentTarget.offsetWidth * 100, + y: e.nativeEvent.offsetY / e.currentTarget.offsetHeight * 100 +}) + +export const TYPE = 'POLYGON' + +/* + * This function checks if the argument pointToCheck exists on the line created between pointA and pointB. + * All point arguments are represented as two element arrays (e.g.: [10, 15]). + */ +function isPointOnLine (pointA, pointB, pointToCheck) { + return (Math.hypot(pointToCheck[0]-pointA[0], pointToCheck[1]-pointA[1]) + + Math.hypot(pointB[0]-pointToCheck[0], pointB[1]-pointToCheck[1]) + === Math.hypot(pointB[0]-pointA[0], pointB[1]-pointA[1])) +} + +/* + * This function checks if the point [x, y] exists on the edge of the polygon created by points polygonPoints. + * The argument polygonPoints is an array of objects (e.g.: [{x: 10, y: 15}, ...]). + */ +function isPointOnPolygonEdge ({ x, y }, polygonPoints) { + if (!polygonPoints || polygonPoints.length < 3 || !x || !y) { return false } + + for (let i = 0; i < polygonPoints.length - 1; ++i) { + if (i === 0) { // First point + if (isPointOnLine(polygonPoints[0], polygonPoints[polygonPoints.length - 1], [x, y])) { + return true + } + } else { + if (isPointOnLine(polygonPoints[i], polygonPoints[i + 1], [x, y])) { + return true + } + } + } + return false +} + +export function intersects ({ x, y }, geometry) { + if (!geometry || !geometry.points || geometry.points.length < 3) return false + + // Switch to point array format (e.g.: [{x: 10, y: 15}, ...] -> [[10, 15], ...]) + const pointsAsArrays = geometry.points.map(point => [point.x, point.y]) + + // Setup GeoJSON json format + const featureCollection = { + type: 'FeatureCollection', + features: [{ + geometry: { + type: 'Polygon', + coordinates: [ pointsAsArrays ] + } + }] + } + + // Determine if point is inside polygon + const lookup = new PolygonLookup(featureCollection) + const poly = lookup.search(x, y) + + // Return whether the point is inside the polygon (poly equals undefined if not) or if the + // point is on the edge (isPointOnPolygonEdge function call) + return (poly !== undefined || isPointOnPolygonEdge({x, y}, pointsAsArrays)) +} + +export function area (geometry) { + if (!geometry || !geometry.points || geometry.points.length < 3) return 0 + + return polygon.area(geometry.points.map(point => [point.x, point.y])) +} + +export const methods = { + onSelectionComplete (annotation) { + return { + ...annotation, + selection: { + ...annotation.selection, + showEditor: true, + mode: 'EDITING' + } + } + }, + + onSelectionClear (annotation) { + return { + ...annotation, + geometry: { + ...annotation.geometry, + points: [] + } + } + }, + + onSelectionUndo (annotation) { + return { + ...annotation, + geometry: { + ...annotation.geometry, + points: annotation.geometry.points.slice(0, -1) + } + } + }, + + onClick (annotation, e) { + const coordOfClick = getCoordPercentage(e) + + return { + ...annotation, + geometry: { + ...annotation.geometry, + type: TYPE, + points: (!annotation.geometry ? [coordOfClick] : [ + ...annotation.geometry.points, + coordOfClick + ]) + }, + selection: { + ...annotation.selection, + mode: 'SELECTING' + } + } + } +} + +export default { + TYPE, + intersects, + area, + methods +} diff --git a/src/selectors.js b/src/selectors.js index c3fb27a..d5c6567 100644 --- a/src/selectors.js +++ b/src/selectors.js @@ -1,3 +1,4 @@ export { default as RectangleSelector } from './hocs/RectangleSelector' export { default as PointSelector } from './hocs/PointSelector' export { default as OvalSelector } from './hocs/OvalSelector' +export { default as PolygonSelector } from './hocs/PolygonSelector' diff --git a/src/utils/pointsUtils.js b/src/utils/pointsUtils.js new file mode 100644 index 0000000..2828bc9 --- /dev/null +++ b/src/utils/pointsUtils.js @@ -0,0 +1,23 @@ +/* + * This util file contains functions for getting different kinds of horizontal/vertical + * points given an array of points (e.g.: [{x: 4, y: 87}, {x: 99, y:7}, ...]). + */ + +/* + * This function returns the horizontally central x-value (number return type), defined + * as the mean of the smallest and largest x-values. + */ +export function getHorizontallyCentralPoint(points) { + const leftMostHorizontalPoint = points.reduce((prev, curr) => (curr.x < prev.x ? curr : prev)).x + const rightMostHorizontalPoint = points.reduce((prev, curr) => (curr.x > prev.x ? curr : prev)).x + + return leftMostHorizontalPoint + Math.round((rightMostHorizontalPoint - leftMostHorizontalPoint) / 2) +} + +/* + * This function returns the vertically lowest y-value (number return type), defined + * as the largest y-value. + */ +export function getVerticallyLowestPoint(points) { + return points.reduce((prev, curr) => (curr.y > prev.y ? curr : prev)).y +} diff --git a/tests/selectors/PolygonSelector.spec.js b/tests/selectors/PolygonSelector.spec.js new file mode 100644 index 0000000..3980deb --- /dev/null +++ b/tests/selectors/PolygonSelector.spec.js @@ -0,0 +1,70 @@ +import { mount } from 'enzyme' +import { expect } from 'chai' +import React from 'react' + +import { PolygonSelector as selector } from '../../src/selectors' + +function createPolygon ({ points } = { points: [{x: 10, y: 10}, {x: 90, y: 10}, {x: 50, y: 90}] }) { + return { + points + } +} + +describe('PolygonSelector', () => { + describe('TYPE', () => { + it('should be a defined string', () => { + expect(selector.TYPE).to.be.a('string') + }) + }) + + describe('intersects', () => { + it('should return true when point is on top left of geometry', () => { + expect( + selector.intersects({ x: 10, y: 10 }, createPolygon()) + ).to.be.true + }) + it('should return true when point is on top right of geometry', () => { + expect( + selector.intersects({ x: 90, y: 10 }, createPolygon()) + ).to.be.true + }) + it('should return true when point is on bottom left of geometry', () => { + expect( + selector.intersects({ x: 40, y: 60 }, createPolygon()) + ).to.be.true + }) + it('should return true when point is on bottom right of geometry', () => { + expect( + selector.intersects({ x: 60, y: 60 }, createPolygon()) + ).to.be.true + }) + it('should return true when point is on bottom center of geometry', () => { + expect( + selector.intersects({ x: 50, y: 90 }, createPolygon()) + ).to.be.true + }) + it('should return true when point is inside geometry', () => { + expect( + selector.intersects({ x: 50, y: 50 }, createPolygon()) + ).to.be.true + }) + it('should return false when point is outside of geometry', () => { + expect(selector.intersects({ x: 0, y: 0 }, createPolygon())).to.be.false + expect(selector.intersects({ x: 100, y: 0 }, createPolygon())).to.be.false + expect(selector.intersects({ x: 0, y: 100 }, createPolygon())).to.be.false + expect(selector.intersects({ x: 100, y: 100 }, createPolygon())).to.be.false + expect(selector.intersects({ x: 10, y: 20 }, createPolygon())).to.be.false + expect(selector.intersects({ x: 90, y: 20 }, createPolygon())).to.be.false + }) + }) + + describe('area', () => { + it('should return geometry area', () => { + expect(selector.area(createPolygon())).to.equal(3200) + }) + }) + + describe('methods', () => { + xit('should be defined') + }) +})