diff --git a/frontend/farm_designer/map/layers/images/__tests__/images_layer_test.tsx b/frontend/farm_designer/map/layers/images/__tests__/images_layer_test.tsx index 3e87bb3b9..b8b69ae30 100644 --- a/frontend/farm_designer/map/layers/images/__tests__/images_layer_test.tsx +++ b/frontend/farm_designer/map/layers/images/__tests__/images_layer_test.tsx @@ -26,13 +26,12 @@ describe("", () => { images: [image], mapTransformProps: fakeMapTransformProps(), cameraCalibrationData: { - offset: { x: "0", y: "0" }, - origin: "TOP_LEFT", - rotation: "0", - scale: "1", - calibrationZ: "0" + offset: { x: undefined, y: undefined }, + origin: undefined, + rotation: undefined, + scale: undefined, + calibrationZ: undefined, }, - sizeOverride: { width: 10, height: 10 }, getConfigValue: jest.fn(), }; } @@ -41,7 +40,7 @@ describe("", () => { const p = fakeProps(); const wrapper = shallow(); 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", () => { diff --git a/frontend/farm_designer/map/layers/images/__tests__/map_image_test.tsx b/frontend/farm_designer/map/layers/images/__tests__/map_image_test.tsx index dd3ae6220..436d2e3d3 100644 --- a/frontend/farm_designer/map/layers/images/__tests__/map_image_test.tsx +++ b/frontend/farm_designer/map/layers/images/__tests__/map_image_test.tsx @@ -45,9 +45,22 @@ describe("", () => { const p = fakeProps(); p.image && (p.image.body.meta = { x: 0, y: 0, z: 0 }); const wrapper = mount(); + wrapper.setState({ width: 100, height: 100 }); 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(); + 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 { size: { width: number, height: number }; sx: number; @@ -71,6 +84,7 @@ describe("", () => { extra?: ExtraTranslationData) => { it(`renders image: INPUT_SET_${num}`, () => { const wrapper = mount(); + wrapper.setState({ width: 480, height: 640 }); expect(wrapper.find("image").props()).toEqual({ xlinkHref: "image_url", x: 0, @@ -100,7 +114,6 @@ describe("", () => { INPUT_SET_1.mapTransformProps = fakeMapTransformProps(); INPUT_SET_1.mapTransformProps.gridSize = { x: 5900, y: 2900 }, INPUT_SET_1.mapTransformProps.quadrant = 3; - INPUT_SET_1.sizeOverride = { width: 480, height: 640 }; const INPUT_SET_2 = cloneDeep(INPUT_SET_1); INPUT_SET_2.image && (INPUT_SET_2.image.body.meta = { @@ -125,13 +138,16 @@ describe("", () => { const INPUT_SET_7 = cloneDeep(INPUT_SET_6); 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); INPUT_SET_8.mapTransformProps.xySwap = true; const DATA = [ INPUT_SET_1, 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 }; @@ -157,6 +173,9 @@ describe("", () => { renderedTest(7, DATA, { 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, { size: expectedSize, sx: 1, sy: 1, tx: 2388.344, ty: 5307.36 }, { rot: 90, sx: -1, sy: 1, tx: -514.624, ty: -514.624 }); diff --git a/frontend/farm_designer/map/layers/images/image_layer.tsx b/frontend/farm_designer/map/layers/images/image_layer.tsx index 8e357a6f4..1b17e9808 100644 --- a/frontend/farm_designer/map/layers/images/image_layer.tsx +++ b/frontend/farm_designer/map/layers/images/image_layer.tsx @@ -6,37 +6,42 @@ import { MapImage } from "./map_image"; import { reverse, cloneDeep } from "lodash"; import { GetWebAppConfigValue } from "../../../../config_storage/actions"; import moment from "moment"; +import { equals } from "../../../../util"; export interface ImageLayerProps { visible: boolean; images: TaggedImage[]; mapTransformProps: MapTransformProps; cameraCalibrationData: CameraCalibrationData; - sizeOverride?: { width: number, height: number }; getConfigValue: GetWebAppConfigValue; } -export function ImageLayer(props: ImageLayerProps) { - const { - visible, images, mapTransformProps, cameraCalibrationData, sizeOverride, - getConfigValue - } = props; - const imageFilterBegin = getConfigValue("photo_filter_begin"); - const imageFilterEnd = getConfigValue("photo_filter_end"); - return - {visible && - reverse(cloneDeep(images)) - .filter(x => !imageFilterEnd || - moment(x.body.created_at).isBefore(imageFilterEnd.toString())) - .filter(x => !imageFilterBegin || - moment(x.body.created_at).isAfter(imageFilterBegin.toString())) - .map(img => - - )} - ; +export class ImageLayer extends React.Component { + + shouldComponentUpdate(nextProps: ImageLayerProps) { + return !equals(this.props, nextProps); + } + + render() { + const { + visible, images, mapTransformProps, cameraCalibrationData, getConfigValue + } = this.props; + const imageFilterBegin = getConfigValue("photo_filter_begin"); + const imageFilterEnd = getConfigValue("photo_filter_end"); + return + {visible && + reverse(cloneDeep(images)) + .filter(x => !imageFilterEnd || + moment(x.body.created_at).isBefore(imageFilterEnd.toString())) + .filter(x => !imageFilterBegin || + moment(x.body.created_at).isAfter(imageFilterBegin.toString())) + .map(img => + + )} + ; + } } diff --git a/frontend/farm_designer/map/layers/images/map_image.tsx b/frontend/farm_designer/map/layers/images/map_image.tsx index e9bbc1c1b..d94cfc273 100644 --- a/frontend/farm_designer/map/layers/images/map_image.tsx +++ b/frontend/farm_designer/map/layers/images/map_image.tsx @@ -4,6 +4,7 @@ import { CameraCalibrationData, BotOriginQuadrant } from "../../../interfaces"; import { MapTransformProps } from "../../interfaces"; import { transformXY } from "../../util"; import { isNumber, round } from "lodash"; +import { equals } from "../../../../util"; const PRECISION = 3; // Number of decimals for image placement coordinates /** Show all images roughly on map when no calibration values are present. */ @@ -33,21 +34,14 @@ const cameraZCheck = Math.abs(imageZ - calibrationZ) < 5; }; -interface ImageSize { - width: number; - height: number; -} - -/* 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; } +/* Get the size of the image at the URL. */ +const getImageSize = ( + url: string, + onLoad: (img: HTMLImageElement) => () => void +): void => { const imageData = new Image(); imageData.src = url; - return { - height: imageData.height || 480, - width: imageData.width || 640 - }; + imageData.onload = onLoad(imageData); }; /* Flip (mirror) image based on orientation of camera. */ @@ -113,11 +107,37 @@ const transform = (props: TransformProps): string => { + 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 { image: TaggedImage | undefined; cameraCalibrationData: CameraCalibrationData; 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. * Require camera calibration data to display the image. */ -// tslint:disable-next-line:cyclomatic-complexity -export function MapImage(props: MapImageProps) { - 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; +export class MapImage extends React.Component { + state: MapImageState = { width: 0, height: 0 }; - /* Check if the image exists. */ - if (image) { - const imageUrl = image.body.attachment_url; - const { x, y, z } = image.body.meta; - const imageAnnotation = image.body.meta.name; - const { width, height } = getImageSize(imageUrl, sizeOverride); - - /* 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, props.mapTransformProps); - const transformProps = { quadrant, qCoords, size, imageOrigin, xySwap }; - return ; - } + shouldComponentUpdate(nextProps: MapImageProps, nextState: MapImageState) { + const propsChanged = !equals(this.props, nextProps); + const stateChanged = !equals(this.state, nextState); + return propsChanged || stateChanged; + } + + imageCallback = (img: HTMLImageElement) => () => { + const { width, height } = img; + this.setState({ width, height }); + }; + + render() { + const { image, cameraCalibrationData } = this.props; + const { + noCalib, imageScale, imageOffsetX, imageOffsetY, imageOrigin + } = parseCalibrationData(cameraCalibrationData); + const { calibrationZ } = cameraCalibrationData; + const { quadrant, xySwap } = this.props.mapTransformProps; + + /* Check if the image exists. */ + if (image && !image.body.attachment_url.includes("placehold")) { + const imageUrl = image.body.attachment_url; + const { x, y, z } = image.body.meta; + 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 ; + } + } + return ; } - return ; } diff --git a/frontend/farmware/index.tsx b/frontend/farmware/index.tsx index 6b4d5ac03..15907fa19 100644 --- a/frontend/farmware/index.tsx +++ b/frontend/farmware/index.tsx @@ -15,7 +15,6 @@ import { FarmwareForm, needsFarmwareForm, farmwareHelpText } from "./farmware_forms"; import { urlFriendly } from "../util"; -import { history } from "../history"; import { ToolTips, Actions } from "../constants"; import { FarmwareInfo } from "./farmware_info"; import { Farmwares, FarmwareManifestInfo } from "./interfaces"; @@ -129,13 +128,9 @@ export class FarmwarePage extends React.Component { type: Actions.SELECT_FARMWARE, payload: "Photos" }); - if (Object.values(this.props.farmwares).length > 0) { - const farmwareNames = Object.values(this.props.farmwares).map(x => x.name); - setActiveFarmwareByName(farmwareNames); - } else { - // Farmware information not available. Load default Farmware page. - history.push("/app/farmware"); - } + const farmwareNames = Object.values(this.props.farmwares).map(x => x.name) + .concat(Object.keys(FARMWARE_NAMES_1ST_PARTY)); + setActiveFarmwareByName(farmwareNames); } /** Load Farmware input panel contents for 1st & 3rd party Farmware. */