Coordinate2DWidget

Source

index.js
import React from 'react';
import PropTypes from 'prop-types';

import equals from 'mout/src/object/equals';

import style from 'PVWStyle/ReactWidgets/Coordinate2DWidget.mcss';

import MouseHandler from '../../../Interaction/Core/MouseHandler';

/*
CoordinateControl class
renders a canvas with a static and stationary crosshair
with two number inputs next to it
*/

function limitValue(val) {
return Math.max(-1.0, Math.min(val, 1.0));
}

export default class Coordinate2DWidget extends React.Component {
constructor(props) {
super(props);
this.state = {
x: props.x,
y: props.y,
};

// Bind callback
this.updateCoordinates = this.updateCoordinates.bind(this);
this.updateX = this.updateX.bind(this);
this.updateY = this.updateY.bind(this);
this.pointerAction = this.pointerAction.bind(this);
this.drawControl = this.drawControl.bind(this);
this.drawControl = this.drawControl.bind(this);
this.drawPlus = this.drawPlus.bind(this);
}

componentDidMount() {
this.drawControl();
this.mouseHandler = new MouseHandler(this.canvas);
this.mouseHandler.attach({
click: this.pointerAction,
mousedown: this.pointerAction,
mouseup: this.pointerAction,
drag: this.pointerAction,
});
}

componentDidUpdate(nextProps, nextState) {
this.drawControl();
}

componentWillUnmount() {
this.mouseHandler.destroy();
}

coordinates() {
return { x: this.state.x, y: this.state.y };
}

updateCoordinates(coords) {
const newCoords = {};
let newVals = false;

['x', 'y'].forEach((el) => {
if ({}.hasOwnProperty.call(coords, el)) {
newCoords[el] = limitValue(parseFloat(coords[el]));
newVals = true;
}
});

if (newVals) {
this.setState(newCoords);
}
}

// no need to limit the values, for updateX/Y, the input already does that.
updateX(e) {
const newVal = parseFloat(e.target.value);
this.setState({ x: newVal });
}

updateY(e) {
const newVal = parseFloat(e.target.value);
this.setState({ y: newVal });
}

// covers clicks, mouseup/down, and drag.
pointerAction(e) {
const rect = this.canvas.getBoundingClientRect();
const x = e.pointers[0].clientX - rect.left - this.props.width / 2;
const y = -(e.pointers[0].clientY - rect.top - this.props.height / 2);
this.setState({
x: limitValue(x / (this.props.width / 2)),
y: limitValue(y / (this.props.height / 2)),
});
}

drawControl() {
const ctx = this.canvas.getContext('2d');
const height = ctx.canvas.height;
const width = ctx.canvas.width;

// clear
ctx.clearRect(0, 0, width, height);

// draw a lightgrey center plus
this.drawPlus('lightgrey');

// draw a plus at {this.state.x, this.state.y},

// convert the values to canvas coords before hand.
this.drawPlus('black', {
x: this.state.x * (this.props.width / 2),
y: -this.state.y * (this.props.height / 2),
});

if (this.props.onChange) {
const currentState = {
x: this.state.x,
y: this.state.y,
};
if (!equals(currentState, this.lastSharedState)) {
this.lastSharedState = currentState;
this.props.onChange(this.lastSharedState);
}
}
}

drawPlus(color, location_) {
const ctx = this.canvas.getContext('2d');
const height = ctx.canvas.height;
const width = ctx.canvas.width;
const lineLen = 5;
let location = location_;

if (location === undefined) {
location = {
x: width / 2,
y: height / 2,
};
} else {
location.x += this.props.width / 2;
location.y += this.props.height / 2;
}

// style
ctx.beginPath();
ctx.lineWidth = 2;
ctx.strokeStyle = color;

// vert
ctx.moveTo(location.x, location.y - lineLen);
ctx.lineTo(location.x, location.y + lineLen);
ctx.stroke();

// horiz
ctx.moveTo(location.x - lineLen, location.y);
ctx.lineTo(location.x + lineLen, location.y);
ctx.stroke();
}

render() {
return (
<section className={style.container}>
<canvas
ref={(c) => {
this.canvas = c;
}}
className={style.canvas}
width={this.props.width}
height={this.props.height}
/>
<section
className={this.props.hideXY ? style.hidden : style.inputContainer}
>
<label className={style.inputLabel}> x: </label>
<input
className={style.input}
type="number"
onChange={this.updateX}
min="-1.0"
max="1.0"
step="0.01"
value={this.state.x}
/>
<br />
<label className={style.inputLabel}> y: </label>
<input
className={style.input}
type="number"
onChange={this.updateY}
min="-1.0"
max="1.0"
step="0.01"
value={this.state.y}
/>
</section>
</section>
);
}
}

Coordinate2DWidget.propTypes = {
height: PropTypes.number,
hideXY: PropTypes.bool,
onChange: PropTypes.func,
width: PropTypes.number,
x: PropTypes.number,
y: PropTypes.number,
};

Coordinate2DWidget.defaultProps = {
hideXY: false,
onChange: undefined,
width: 50,
height: 50,
x: 0,
y: 0,
};
test.js
import CoordinateControl from './index';
import expect from 'expect';
import React from 'react';
import ReactDOM from 'react-dom';
import TestUtils from 'react/lib/ReactTestUtils';

class Mock extends React.Component {
constructor(props) {
super(props);
this.state = { x: 0, y: 0 };
this.updateCoords = this.updateCoords.bind(this);
}

updateCoords(newVals) {
this.setState({ x: newVals.x, y: newVals.y });
}

render() {
return <CoordinateControl onChange={this.updateCoords} />;
}
}

describe('CoordinateControl', function () {
afterEach(function (done) {
ReactDOM.unmountComponentAtNode(document.body);
document.body.innerHTML = '';
setTimeout(done);
});

function convertToCoord(val, size) {
return ((val * 2) / (size * 2) - 0.5) * 2;
}

it('has two inputs and a canvas', function () {
var el = TestUtils.renderIntoDocument(<CoordinateControl hideXY={false} />),
canvas = TestUtils.scryRenderedDOMComponentsWithTag(el, 'canvas'),
inputs = TestUtils.scryRenderedDOMComponentsWithTag(el, 'input');
expect(canvas.length).toBe(1);
expect(inputs.length).toBe(2);
expect(inputs[0].value).toBe(inputs[1].value);
});
it('can hide XY inputs', function () {
var el = TestUtils.renderIntoDocument(<CoordinateControl hideXY={true} />),
inputsContainer = TestUtils.findRenderedDOMComponentWithClass(
el,
'inputs'
);
expect(inputsContainer.classList.contains('is-hidden')).toBe(true);
});
it('keeps coordinate state and XY inputs in sync', function () {
var el = TestUtils.renderIntoDocument(<CoordinateControl />),
inputs = TestUtils.scryRenderedDOMComponentsWithTag(el, 'input'),
newXVal = 0.6,
newYVal = -0.45;
TestUtils.Simulate.change(inputs[0], { target: { value: newXVal } });
TestUtils.Simulate.change(inputs[1], { target: { value: newYVal } });

expect(el.coordinates()).toEqual({ x: newXVal, y: newYVal });
});
it('can update coordinates externally', function () {
var el = TestUtils.renderIntoDocument(
<CoordinateControl x={0.25} y={0.45} />
),
newXVal = -0.25,
newYVal = -0.45;

el.updateCoordinates({ x: newXVal, y: newYVal });
expect(el.coordinates()).toEqual({ x: newXVal, y: newYVal });
});
it('updates values when dragged', function () {
var size = 400,
el = TestUtils.renderIntoDocument(
<CoordinateControl x={0.25} y={0.45} width={size} height={size} />
),
canvas = TestUtils.findRenderedDOMComponentWithTag(el, 'canvas'),
newXVal = 100,
newYVal = 200;

el.mouseHandler.emit('drag', {
pointers: [{ clientX: newXVal, clientY: newYVal }],
});
expect(el.coordinates()).toEqual({
x: convertToCoord(newXVal, size),
y: -convertToCoord(newYVal, size),
});
});
it('triggers a given onChange function', function () {
var el = TestUtils.renderIntoDocument(<Mock />),
input = TestUtils.scryRenderedDOMComponentsWithTag(el, 'input')[0],
newXVal = 0.88;

TestUtils.Simulate.change(input, { target: { value: newXVal } });
expect(el.state.x).toEqual(newXVal);
});
it('takes a click on the canvas and updates it to state', function () {
var size = 400,
el = TestUtils.renderIntoDocument(
<CoordinateControl width={size} height={size} />
),
canvas = TestUtils.findRenderedDOMComponentWithTag(el, 'canvas'),
newXVal = 100,
newYVal = 200;
expect(canvas.width).toBe(size);
expect(canvas.height).toBe(size);

el.mouseHandler.emit('click', {
pointers: [{ clientX: newXVal, clientY: newYVal }],
});
expect(el.state.x).toBe(convertToCoord(newXVal, size));
expect(el.state.y).toBe(-convertToCoord(newYVal, size));

newXVal = 350;
newYVal = 120;
el.mouseHandler.emit('click', {
pointers: [{ clientX: newXVal, clientY: newYVal }],
});
expect(el.state.x).toBe(convertToCoord(newXVal, size));
expect(el.state.y).toBe(-convertToCoord(newYVal, size));
});
it('destroys listeners when removed', function () {
var el = TestUtils.renderIntoDocument(<CoordinateControl />);
ReactDOM.unmountComponentAtNode(ReactDOM.findDOMNode(el).parentNode);
expect(el.mouseHandler.hammer.element).toNotExist();
});
});