Merge pull request #1173 from FarmBot/staging

v7.2.6 - Happy Hibiscus
This commit is contained in:
Rick Carlino 2019-04-30 09:28:31 -05:00 committed by GitHub
commit 396424c217
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 142 additions and 92 deletions

View file

@ -26,13 +26,12 @@ describe("<ImageLayer/>", () => {
images: [image], images: [image],
mapTransformProps: fakeMapTransformProps(), mapTransformProps: fakeMapTransformProps(),
cameraCalibrationData: { cameraCalibrationData: {
offset: { x: "0", y: "0" }, offset: { x: undefined, y: undefined },
origin: "TOP_LEFT", origin: undefined,
rotation: "0", rotation: undefined,
scale: "1", scale: undefined,
calibrationZ: "0" calibrationZ: undefined,
}, },
sizeOverride: { width: 10, height: 10 },
getConfigValue: jest.fn(), getConfigValue: jest.fn(),
}; };
} }
@ -41,7 +40,7 @@ describe("<ImageLayer/>", () => {
const p = fakeProps(); const p = fakeProps();
const wrapper = shallow(<ImageLayer {...p} />); const wrapper = shallow(<ImageLayer {...p} />);
const layer = wrapper.find("#image-layer"); const layer = wrapper.find("#image-layer");
expect(layer.find("MapImage").html()).toContain("x=\"0\""); expect(layer.find("MapImage").html()).toContain("image");
}); });
it("toggles visibility off", () => { it("toggles visibility off", () => {

View file

@ -45,9 +45,22 @@ describe("<MapImage />", () => {
const p = fakeProps(); const p = fakeProps();
p.image && (p.image.body.meta = { x: 0, y: 0, z: 0 }); p.image && (p.image.body.meta = { x: 0, y: 0, z: 0 });
const wrapper = mount(<MapImage {...p} />); const wrapper = mount(<MapImage {...p} />);
wrapper.setState({ width: 100, height: 100 });
expect(wrapper.html()).toContain("image_url"); expect(wrapper.html()).toContain("image_url");
}); });
it("gets image size", () => {
const p = fakeProps();
p.image && (p.image.body.meta = { x: 0, y: 0, z: 0 });
const wrapper = mount<MapImage>(<MapImage {...p} />);
expect(wrapper.state()).toEqual({ width: 0, height: 0 });
const img = new Image();
img.width = 100;
img.height = 200;
wrapper.instance().imageCallback(img)();
expect(wrapper.state()).toEqual({ width: 100, height: 200 });
});
interface ExpectedData { interface ExpectedData {
size: { width: number, height: number }; size: { width: number, height: number };
sx: number; sx: number;
@ -71,6 +84,7 @@ describe("<MapImage />", () => {
extra?: ExtraTranslationData) => { extra?: ExtraTranslationData) => {
it(`renders image: INPUT_SET_${num}`, () => { it(`renders image: INPUT_SET_${num}`, () => {
const wrapper = mount(<MapImage {...inputData[num]} />); const wrapper = mount(<MapImage {...inputData[num]} />);
wrapper.setState({ width: 480, height: 640 });
expect(wrapper.find("image").props()).toEqual({ expect(wrapper.find("image").props()).toEqual({
xlinkHref: "image_url", xlinkHref: "image_url",
x: 0, x: 0,
@ -100,7 +114,6 @@ describe("<MapImage />", () => {
INPUT_SET_1.mapTransformProps = fakeMapTransformProps(); INPUT_SET_1.mapTransformProps = fakeMapTransformProps();
INPUT_SET_1.mapTransformProps.gridSize = { x: 5900, y: 2900 }, INPUT_SET_1.mapTransformProps.gridSize = { x: 5900, y: 2900 },
INPUT_SET_1.mapTransformProps.quadrant = 3; INPUT_SET_1.mapTransformProps.quadrant = 3;
INPUT_SET_1.sizeOverride = { width: 480, height: 640 };
const INPUT_SET_2 = cloneDeep(INPUT_SET_1); const INPUT_SET_2 = cloneDeep(INPUT_SET_1);
INPUT_SET_2.image && (INPUT_SET_2.image.body.meta = { INPUT_SET_2.image && (INPUT_SET_2.image.body.meta = {
@ -125,13 +138,16 @@ describe("<MapImage />", () => {
const INPUT_SET_7 = cloneDeep(INPUT_SET_6); const INPUT_SET_7 = cloneDeep(INPUT_SET_6);
INPUT_SET_7.cameraCalibrationData.origin = "BOTTOM_RIGHT"; INPUT_SET_7.cameraCalibrationData.origin = "BOTTOM_RIGHT";
const INPUT_SET_9 = cloneDeep(INPUT_SET_6);
INPUT_SET_9.cameraCalibrationData.origin = "TOP_LEFT";
const INPUT_SET_8 = cloneDeep(INPUT_SET_7); const INPUT_SET_8 = cloneDeep(INPUT_SET_7);
INPUT_SET_8.mapTransformProps.xySwap = true; INPUT_SET_8.mapTransformProps.xySwap = true;
const DATA = [ const DATA = [
INPUT_SET_1, INPUT_SET_1,
INPUT_SET_1, INPUT_SET_2, INPUT_SET_3, INPUT_SET_4, INPUT_SET_5, INPUT_SET_1, INPUT_SET_2, INPUT_SET_3, INPUT_SET_4, INPUT_SET_5,
INPUT_SET_6, INPUT_SET_7, INPUT_SET_8 INPUT_SET_6, INPUT_SET_7, INPUT_SET_8, INPUT_SET_9,
]; ];
const expectedSize = { width: 385.968, height: 514.624 }; const expectedSize = { width: 385.968, height: 514.624 };
@ -157,6 +173,9 @@ describe("<MapImage />", () => {
renderedTest(7, DATA, { renderedTest(7, DATA, {
size: expectedSize, sx: 1, sy: 1, tx: 5436.016, ty: 2259.688 size: expectedSize, sx: 1, sy: 1, tx: 5436.016, ty: 2259.688
}); });
renderedTest(9, DATA, {
size: expectedSize, sx: -1, sy: -1, tx: -5821.984, ty: -2774.312
});
renderedTest(8, DATA, { renderedTest(8, DATA, {
size: expectedSize, sx: 1, sy: 1, tx: 2388.344, ty: 5307.36 size: expectedSize, sx: 1, sy: 1, tx: 2388.344, ty: 5307.36
}, { rot: 90, sx: -1, sy: 1, tx: -514.624, ty: -514.624 }); }, { rot: 90, sx: -1, sy: 1, tx: -514.624, ty: -514.624 });

View file

@ -6,37 +6,42 @@ import { MapImage } from "./map_image";
import { reverse, cloneDeep } from "lodash"; import { reverse, cloneDeep } from "lodash";
import { GetWebAppConfigValue } from "../../../../config_storage/actions"; import { GetWebAppConfigValue } from "../../../../config_storage/actions";
import moment from "moment"; import moment from "moment";
import { equals } from "../../../../util";
export interface ImageLayerProps { export interface ImageLayerProps {
visible: boolean; visible: boolean;
images: TaggedImage[]; images: TaggedImage[];
mapTransformProps: MapTransformProps; mapTransformProps: MapTransformProps;
cameraCalibrationData: CameraCalibrationData; cameraCalibrationData: CameraCalibrationData;
sizeOverride?: { width: number, height: number };
getConfigValue: GetWebAppConfigValue; getConfigValue: GetWebAppConfigValue;
} }
export function ImageLayer(props: ImageLayerProps) { export class ImageLayer extends React.Component<ImageLayerProps> {
const {
visible, images, mapTransformProps, cameraCalibrationData, sizeOverride, shouldComponentUpdate(nextProps: ImageLayerProps) {
getConfigValue return !equals(this.props, nextProps);
} = props; }
const imageFilterBegin = getConfigValue("photo_filter_begin");
const imageFilterEnd = getConfigValue("photo_filter_end"); render() {
return <g id="image-layer"> const {
{visible && visible, images, mapTransformProps, cameraCalibrationData, getConfigValue
reverse(cloneDeep(images)) } = this.props;
.filter(x => !imageFilterEnd || const imageFilterBegin = getConfigValue("photo_filter_begin");
moment(x.body.created_at).isBefore(imageFilterEnd.toString())) const imageFilterEnd = getConfigValue("photo_filter_end");
.filter(x => !imageFilterBegin || return <g id="image-layer">
moment(x.body.created_at).isAfter(imageFilterBegin.toString())) {visible &&
.map(img => reverse(cloneDeep(images))
<MapImage .filter(x => !imageFilterEnd ||
image={img} moment(x.body.created_at).isBefore(imageFilterEnd.toString()))
key={"image_" + img.body.id} .filter(x => !imageFilterBegin ||
cameraCalibrationData={cameraCalibrationData} moment(x.body.created_at).isAfter(imageFilterBegin.toString()))
sizeOverride={sizeOverride} .map(img =>
mapTransformProps={mapTransformProps} /> <MapImage
)} image={img}
</g>; key={"image_" + img.body.id}
cameraCalibrationData={cameraCalibrationData}
mapTransformProps={mapTransformProps} />
)}
</g>;
}
} }

View file

@ -4,6 +4,7 @@ import { CameraCalibrationData, BotOriginQuadrant } from "../../../interfaces";
import { MapTransformProps } from "../../interfaces"; import { MapTransformProps } from "../../interfaces";
import { transformXY } from "../../util"; import { transformXY } from "../../util";
import { isNumber, round } from "lodash"; import { isNumber, round } from "lodash";
import { equals } from "../../../../util";
const PRECISION = 3; // Number of decimals for image placement coordinates const PRECISION = 3; // Number of decimals for image placement coordinates
/** Show all images roughly on map when no calibration values are present. */ /** Show all images roughly on map when no calibration values are present. */
@ -33,21 +34,14 @@ const cameraZCheck =
Math.abs(imageZ - calibrationZ) < 5; Math.abs(imageZ - calibrationZ) < 5;
}; };
interface ImageSize { /* Get the size of the image at the URL. */
width: number; const getImageSize = (
height: number; url: string,
} onLoad: (img: HTMLImageElement) => () => void
): void => {
/* Get the size of the image at the URL. Allow overriding for tests. */
const getImageSize = (url: string, size?: ImageSize): ImageSize => {
if (url.includes("placehold")) { return { width: 0, height: 0 }; }
if (size) { return size; }
const imageData = new Image(); const imageData = new Image();
imageData.src = url; imageData.src = url;
return { imageData.onload = onLoad(imageData);
height: imageData.height || 480,
width: imageData.width || 640
};
}; };
/* Flip (mirror) image based on orientation of camera. */ /* Flip (mirror) image based on orientation of camera. */
@ -113,11 +107,37 @@ const transform = (props: TransformProps): string => {
+ xySwapTransform; + xySwapTransform;
}; };
interface ParsedCalibrationData {
noCalib: boolean;
imageScale: number | undefined;
imageOffsetX: number | undefined;
imageOffsetY: number | undefined;
imageOrigin: string | undefined;
}
/** If calibration data exists, parse it, usually to a number.
* Otherwise, return values for pre-calibration preview. */
const parseCalibrationData =
(props: CameraCalibrationData): ParsedCalibrationData => {
const { scale, offset, origin } = props;
const noCalib = PRE_CALIBRATION_PREVIEW && !parse(scale);
const imageScale = noCalib ? 0.6 : parse(scale);
const imageOffsetX = noCalib ? 0 : parse(offset.x);
const imageOffsetY = noCalib ? 0 : parse(offset.y);
const cleanOrigin = origin ? origin.split("\"").join("") : undefined;
const imageOrigin = noCalib ? "BOTTOM_LEFT" : cleanOrigin;
return { noCalib, imageScale, imageOffsetX, imageOffsetY, imageOrigin };
};
export interface MapImageProps { export interface MapImageProps {
image: TaggedImage | undefined; image: TaggedImage | undefined;
cameraCalibrationData: CameraCalibrationData; cameraCalibrationData: CameraCalibrationData;
mapTransformProps: MapTransformProps; mapTransformProps: MapTransformProps;
sizeOverride?: ImageSize; }
interface MapImageState {
width: number;
height: number;
} }
/* /*
@ -125,43 +145,55 @@ export interface MapImageProps {
* Assume the image that is provided from the Farmware is rotated correctly. * Assume the image that is provided from the Farmware is rotated correctly.
* Require camera calibration data to display the image. * Require camera calibration data to display the image.
*/ */
// tslint:disable-next-line:cyclomatic-complexity export class MapImage extends React.Component<MapImageProps, MapImageState> {
export function MapImage(props: MapImageProps) { state: MapImageState = { width: 0, height: 0 };
const { image, cameraCalibrationData, sizeOverride } = props;
const { scale, offset, origin, calibrationZ } = cameraCalibrationData;
const noCalib = PRE_CALIBRATION_PREVIEW && !parse(scale);
const imageScale = noCalib ? 1.5 : parse(scale);
const imageOffsetX = noCalib ? 0 : parse(offset.x);
const imageOffsetY = noCalib ? 0 : parse(offset.y);
const cleanOrigin = origin ? origin.split("\"").join("") : undefined;
const imageOrigin = noCalib ? "BOTTOM_LEFT" : cleanOrigin;
const { quadrant, xySwap } = props.mapTransformProps;
/* Check if the image exists. */ shouldComponentUpdate(nextProps: MapImageProps, nextState: MapImageState) {
if (image) { const propsChanged = !equals(this.props, nextProps);
const imageUrl = image.body.attachment_url; const stateChanged = !equals(this.state, nextState);
const { x, y, z } = image.body.meta; return propsChanged || stateChanged;
const imageAnnotation = image.body.meta.name; }
const { width, height } = getImageSize(imageUrl, sizeOverride);
imageCallback = (img: HTMLImageElement) => () => {
/* Check for all necessary camera calibration and image data. */ const { width, height } = img;
if (isNumber(x) && isNumber(y) && height > 0 && width > 0 && this.setState({ width, height });
isNumber(imageScale) && imageScale > 0 && };
cameraZCheck(z, calibrationZ) && isRotated(imageAnnotation, noCalib) &&
isNumber(imageOffsetX) && isNumber(imageOffsetY) && imageOrigin) { render() {
/* Use pixel to coordinate scale to scale image. */ const { image, cameraCalibrationData } = this.props;
const size = { x: width * imageScale, y: height * imageScale }; const {
const o = { // Coordinates of top left corner of image for placement noCalib, imageScale, imageOffsetX, imageOffsetY, imageOrigin
x: x + imageOffsetX - size.x / 2, } = parseCalibrationData(cameraCalibrationData);
y: y + imageOffsetY - size.y / 2 const { calibrationZ } = cameraCalibrationData;
}; const { quadrant, xySwap } = this.props.mapTransformProps;
const qCoords = transformXY(o.x, o.y, props.mapTransformProps);
const transformProps = { quadrant, qCoords, size, imageOrigin, xySwap }; /* Check if the image exists. */
return <image if (image && !image.body.attachment_url.includes("placehold")) {
xlinkHref={imageUrl} const imageUrl = image.body.attachment_url;
height={size.y} width={size.x} x={0} y={0} const { x, y, z } = image.body.meta;
transform={transform(transformProps)} />; const imageAnnotation = image.body.meta.name;
} getImageSize(imageUrl, this.imageCallback);
const { width, height } = this.state;
/* Check for all necessary camera calibration and image data. */
if (isNumber(x) && isNumber(y) && height > 0 && width > 0 &&
isNumber(imageScale) && imageScale > 0 &&
cameraZCheck(z, calibrationZ) && isRotated(imageAnnotation, noCalib) &&
isNumber(imageOffsetX) && isNumber(imageOffsetY) && imageOrigin) {
/* Use pixel to coordinate scale to scale image. */
const size = { x: width * imageScale, y: height * imageScale };
const o = { // Coordinates of top left corner of image for placement
x: x + imageOffsetX - size.x / 2,
y: y + imageOffsetY - size.y / 2
};
const qCoords = transformXY(o.x, o.y, this.props.mapTransformProps);
const transformProps = { quadrant, qCoords, size, imageOrigin, xySwap };
return <image
xlinkHref={imageUrl}
height={size.y} width={size.x} x={0} y={0}
transform={transform(transformProps)} />;
}
}
return <image />;
} }
return <image />;
} }

View file

@ -15,7 +15,6 @@ import {
FarmwareForm, needsFarmwareForm, farmwareHelpText FarmwareForm, needsFarmwareForm, farmwareHelpText
} from "./farmware_forms"; } from "./farmware_forms";
import { urlFriendly } from "../util"; import { urlFriendly } from "../util";
import { history } from "../history";
import { ToolTips, Actions } from "../constants"; import { ToolTips, Actions } from "../constants";
import { FarmwareInfo } from "./farmware_info"; import { FarmwareInfo } from "./farmware_info";
import { Farmwares, FarmwareManifestInfo } from "./interfaces"; import { Farmwares, FarmwareManifestInfo } from "./interfaces";
@ -129,13 +128,9 @@ export class FarmwarePage extends React.Component<FarmwareProps, {}> {
type: Actions.SELECT_FARMWARE, type: Actions.SELECT_FARMWARE,
payload: "Photos" payload: "Photos"
}); });
if (Object.values(this.props.farmwares).length > 0) { const farmwareNames = Object.values(this.props.farmwares).map(x => x.name)
const farmwareNames = Object.values(this.props.farmwares).map(x => x.name); .concat(Object.keys(FARMWARE_NAMES_1ST_PARTY));
setActiveFarmwareByName(farmwareNames); setActiveFarmwareByName(farmwareNames);
} else {
// Farmware information not available. Load default Farmware page.
history.push("/app/farmware");
}
} }
/** Load Farmware input panel contents for 1st & 3rd party Farmware. */ /** Load Farmware input panel contents for 1st & 3rd party Farmware. */