FarmBot/Farmbot-Web-App

View on GitHub
frontend/farm_designer/map/layers/farmbot/bot_figure.tsx

Summary

Maintainability
C
1 day
Test Coverage
import React from "react";
import { AxisNumberProperty, MapTransformProps } from "../../interfaces";
import { getMapSize, transformXY } from "../../util";
import { BotPosition } from "../../../../devices/interfaces";
import { Color } from "../../../../ui";
import { botPositionLabel } from "./bot_position_label";
import { RotatedTool } from "../tool_slots/tool_graphics";
import { ToolGraphicProps } from "../../tool_graphics/interfaces";
import { reduceToolName } from "../../tool_graphics/all_tools";
import { ThreeInOneToolHead } from "../../tool_graphics/three_in_one_toolhead";
import { noop, round } from "lodash";
import { ToolPulloutDirection } from "farmbot/dist/resources/api_resources";
import { MountedToolInfo, CameraCalibrationData } from "../../../interfaces";
import {
  mapImagePositionData, cropAmount, largeCrop, MapImagePositionData,
  rotated90degrees,
} from "../images/map_image";

export interface BotFigureProps {
  figureName: string;
  position: BotPosition;
  mapTransformProps: MapTransformProps;
  plantAreaOffset: AxisNumberProperty;
  eStopStatus?: boolean;
  mountedToolInfo?: MountedToolInfo;
  cameraCalibrationData?: CameraCalibrationData;
  cameraViewArea?: boolean;
  cropPhotos?: boolean;
  showUncroppedArea?: boolean;
  color?: Color;
}

interface BotFigureState {
  hovered: boolean;
}

export class BotFigure extends
  React.Component<BotFigureProps, BotFigureState> {
  state: BotFigureState = { hovered: false };

  setHover = (state: boolean) => { this.setState({ hovered: state }); };

  getToolProps(positionQ: { qx: number, qy: number }): ToolGraphicProps {
    return {
      toolName: this.props.mountedToolInfo?.name,
      x: positionQ.qx,
      y: positionQ.qy,
      hovered: this.state.hovered,
      dispatch: noop,
      uuid: "utm",
      toolTransformProps: {
        xySwap: this.props.mapTransformProps.xySwap,
        quadrant: this.props.mapTransformProps.quadrant,
      },
      pulloutDirection: this.props.mountedToolInfo?.pulloutDirection
        || ToolPulloutDirection.POSITIVE_X,
      flipped: !!this.props.mountedToolInfo?.flipped,
    };
  }

  get color() {
    if (this.props.color) { return this.props.color; }
    return this.props.eStopStatus ? Color.virtualRed : Color.darkGray;
  }

  get opacity() { return this.props.figureName.includes("encoder") ? 0.25 : 0.5; }

  get positionQ() {
    return transformXY(
      (this.props.position.x || 0),
      (this.props.position.y || 0),
      this.props.mapTransformProps);
  }

  MountedTool = ({ toolName }: { toolName: string | undefined }) =>
    <g id="mounted-tool">
      <RotatedTool
        tool={reduceToolName(toolName)}
        toolProps={this.getToolProps(this.positionQ)} />
      <circle
        cx={this.positionQ.qx}
        cy={this.positionQ.qy}
        r={32}
        stroke={this.color}
        strokeWidth={6}
        opacity={0.8}
        fill={"none"} />
    </g>;

  UTM = () =>
    !this.props.mountedToolInfo?.noUTM
      ? <circle id="UTM"
        cx={this.positionQ.qx}
        cy={this.positionQ.qy}
        r={35}
        fillOpacity={this.opacity}
        fill={this.color} />
      : <ThreeInOneToolHead
        x={this.positionQ.qx}
        y={this.positionQ.qy}
        toolTransformProps={this.props.mapTransformProps}
        pulloutDirection={ToolPulloutDirection.POSITIVE_X}
        color={this.color} />;

  render() {
    const { figureName, position, plantAreaOffset, mapTransformProps,
    } = this.props;
    const { xySwap } = mapTransformProps;
    const mapSize = getMapSize(mapTransformProps, plantAreaOffset);
    return <g id={figureName}>
      <rect id="gantry"
        x={xySwap ? -plantAreaOffset.x : this.positionQ.qx - 10}
        y={xySwap ? this.positionQ.qy - 10 : -plantAreaOffset.y}
        width={xySwap ? mapSize.w : 20}
        height={xySwap ? 20 : mapSize.h}
        fillOpacity={this.opacity}
        fill={this.color} />
      <g id="UTM-wrapper" style={{ pointerEvents: "all" }}
        onMouseOver={() => this.setHover(true)}
        onMouseLeave={() => this.setHover(false)}
        fillOpacity={this.opacity}
        fill={this.color}>
        {this.props.mountedToolInfo?.name
          ? <this.MountedTool toolName={this.props.mountedToolInfo.name} />
          : <this.UTM />}
      </g>
      {this.props.cameraViewArea && this.props.cameraCalibrationData &&
        <CameraViewArea
          position={this.props.position}
          cropPhotos={this.props.cropPhotos}
          showUncroppedArea={this.props.showUncroppedArea}
          cameraCalibrationData={this.props.cameraCalibrationData}
          mapTransformProps={mapTransformProps} />}
      <text
        visibility={this.state.hovered ? "visible" : "hidden"}
        x={this.positionQ.qx}
        y={this.positionQ.qy}
        dx={xySwap ? 0 : 40}
        dy={xySwap ? 55 : 0}
        dominantBaseline="central"
        textAnchor={xySwap ? "middle" : "start"}
        fontSize={24}
        fill={Color.darkGray}>
        {botPositionLabel(position)}
      </text>
    </g>;
  }
}

const parseEnv = (value: string | undefined): number => parseFloat(value || "0");

interface CameraViewAreaProps {
  position: BotPosition;
  cameraCalibrationData: CameraCalibrationData;
  mapTransformProps: MapTransformProps;
  cropPhotos: boolean | undefined;
  showUncroppedArea: boolean | undefined;
  logVisual?: boolean;
}

// eslint-disable-next-line complexity
export const CameraViewArea = (props: CameraViewAreaProps) => {
  const { cameraCalibrationData, mapTransformProps, cropPhotos } = props;
  const { xySwap } = mapTransformProps;
  const { x, y } = props.position;
  const { center, scale, rotation } = props.cameraCalibrationData;
  const parsedScale = parseEnv(scale);
  const rotationAngle = Math.abs(parseEnv(rotation));
  const cameraRotated = rotated90degrees(rotationAngle);
  const viewArea = {
    width: parseEnv(center.x) * 2,
    height: parseEnv(center.y) * 2,
  };
  const scaledCenter = {
    x: round(viewArea.width / 2 * parsedScale),
    y: round(viewArea.height / 2 * parsedScale),
  };
  const scaledShortEdge = Math.min(viewArea.width, viewArea.height) * parsedScale;
  const crop = cropAmount(rotationAngle, viewArea);
  const snappedViewArea = {
    width: parseEnv(center[cameraRotated && !xySwap ? "y" : "x"]) * 2,
    height: parseEnv(center[cameraRotated && !xySwap ? "x" : "y"]) * 2
  };
  const common = { x, y, cameraCalibrationData, mapTransformProps };
  const angledView = mapImagePositionData({
    ...common,
    width: viewArea.width,
    height: viewArea.height,
    alreadyRotated: false,
  });
  const snappedView = mapImagePositionData({
    ...common,
    width: snappedViewArea.width,
    height: snappedViewArea.height,
    alreadyRotated: true,
    noRotation: xySwap && cameraRotated,
  });
  const croppedView = mapImagePositionData({
    ...common,
    width: snappedViewArea.width - crop,
    height: snappedViewArea.height - crop,
    alreadyRotated: true,
    noRotation: xySwap && cameraRotated,
  });
  const croppedLogVisual = props.logVisual && cropPhotos;
  const showCroppedArea = !cropPhotos || props.showUncroppedArea;
  return <g id="camera-view-area-wrapper" fill={"none"}
    stroke={Color.darkGray} strokeWidth={2} strokeOpacity={0.75}>
    <clipPath id={`snapped-camera-view-area-clip-path-${x}-${y}`}>
      <ViewRectangle position={snappedView} />
    </clipPath>
    {angledView && <ViewCircle id={"camera-photo-center"}
      center={scaledCenter} radius={5} position={angledView} />}
    {props.logVisual &&
      <ImageLogVisuals position={cropPhotos ? croppedView : angledView} />}
    {!props.logVisual && showCroppedArea &&
      <ViewRectangle id={"snapped-camera-view-area"}
        dashed={cropPhotos} position={snappedView} />}
    {!croppedLogVisual && showCroppedArea &&
      <g id={"angled-camera-view-area-wrapper"}
        clipPath={props.logVisual
          ? undefined
          : `url(#snapped-camera-view-area-clip-path-${x}-${y})`}>
        <ViewRectangle id={"angled-camera-view-area"}
          dashed={cropPhotos} position={angledView} />
      </g>}
    {cropPhotos && croppedView && (largeCrop(rotationAngle) && angledView
      ? <ViewCircle id={"cropped-camera-view-area"}
        center={scaledCenter}
        radius={scaledShortEdge / 2}
        position={angledView} />
      : <ViewRectangle id={"cropped-camera-view-area"}
        position={croppedView} />)}
  </g>;
};

interface ViewRectangleProps {
  id?: string;
  position: MapImagePositionData | undefined;
  dashed?: boolean;
}

const ViewRectangle = (props: ViewRectangleProps) =>
  <rect id={props.id}
    strokeDasharray={props.dashed ? "4 5" : "none"}
    x={0}
    y={0}
    width={props.position?.width}
    height={props.position?.height}
    data-comment={props.position?.comment}
    style={{
      transformOrigin: props.position?.transformOrigin,
      transform: props.position?.transform,
    }} />;

interface ViewCircleProps {
  id: string;
  center: Record<"x" | "y", number>;
  radius: number;
  position: MapImagePositionData;
}

const ViewCircle = (props: ViewCircleProps) =>
  <circle id={props.id}
    cx={props.center.x}
    cy={props.center.y}
    r={props.radius}
    data-comment={props.position.comment}
    style={{
      transformOrigin: props.position.transformOrigin,
      transform: props.position.transform,
    }} />;

interface ImageLogVisualsProps {
  position: MapImagePositionData | undefined;
}

const ImageLogVisuals = (props: ImageLogVisualsProps) => {
  if (!props.position) { return <g />; }
  const { width, height, transform, transformOrigin } = props.position;
  return <svg id={"image-log-visuals"} width={width} height={height}>
    <defs>
      <linearGradient id={"camera-scan-fill"}
        x1={0} y1={0} x2={"100%"} y2={0}>
        <stop offset={"0%"} stopColor={Color.cyan} stopOpacity={0} />
        <stop offset={"50%"} stopColor={Color.cyan} stopOpacity={0.7} />
        <stop offset={"100%"} stopColor={Color.cyan} stopOpacity={0} />
      </linearGradient>
    </defs>
    <rect className={"img-full"} style={{ transformOrigin, transform }} />
    <g id={"scan-wrapper"} style={{ transformOrigin, transform }}>
      <rect className={"img-scan"}
        height={height} fill={"url(#camera-scan-fill)"} />
    </g>
  </svg>;
};