Farmbot-Web-App/webpack/farm_designer/map/garden_map.tsx

531 lines
18 KiB
TypeScript

import * as React from "react";
import { t } from "i18next";
import { error } from "farmbot-toastr";
import { Plant, DEFAULT_PLANT_RADIUS } from "../plant";
import { movePlant, closePlantInfo, unselectPlant } from "../actions";
import * as moment from "moment";
import { GardenMapProps, GardenMapState } from "../interfaces";
import { getPathArray } from "../../history";
import { initSave, save, edit } from "../../api/crud";
import { SpecialStatus } from "farmbot";
import {
translateScreenToGarden,
round,
ScreenToGardenParams,
transformXY,
getMapSize
} from "./util";
import { findBySlug } from "../search_selectors";
import { Grid } from "./grid";
import { MapBackground } from "./map_background";
import {
PlantLayer,
SpreadLayer,
PointLayer,
ToolSlotLayer,
FarmBotLayer,
HoveredPlantLayer,
DragHelperLayer,
ImageLayer,
} from "./layers";
import { cachedCrop } from "../../open_farm/icons";
import { AxisNumberProperty, MapTransformProps, TaggedPlant } from "./interfaces";
import { SelectionBox, SelectionBoxData } from "./selection_box";
import { Actions, Content } from "../../constants";
import { isNumber, last, isString } from "lodash";
import { TargetCoordinate } from "./target_coordinate";
import { DrawnPoint } from "./drawn_point";
import { Bugs, showBugs } from "./easter_eggs/bugs";
import { BooleanSetting } from "../../session_keys";
import { savedGardenOpen } from "../saved_gardens/saved_gardens";
import { unpackUUID } from "../../util";
import { SensorReadingsLayer } from "./layers/sensor_readings_layer";
/** Garden map interaction modes. */
export enum Mode {
none = "none",
boxSelect = "boxSelect",
clickToAdd = "clickToAdd",
editPlant = "editPlant",
addPlant = "addPlant",
moveTo = "moveTo",
createPoint = "createPoint",
templateView = "templateView",
}
/** Determine the current map mode based on path. */
export const getMode = (): Mode => {
const pathArray = getPathArray();
if (pathArray) {
if (pathArray[6] === "add") { return Mode.clickToAdd; }
if (pathArray[5] === "edit") { return Mode.editPlant; }
if (pathArray[6] === "edit") { return Mode.editPlant; }
if (pathArray[4] === "select") { return Mode.boxSelect; }
if (pathArray[4] === "crop_search") { return Mode.addPlant; }
if (pathArray[4] === "move_to") { return Mode.moveTo; }
if (pathArray[4] === "create_point") { return Mode.createPoint; }
if (savedGardenOpen(pathArray)) { return Mode.templateView; }
}
return Mode.none;
};
const newPlant = (props: {
x: number,
y: number,
slug: string,
cropName: string,
openedSavedGarden: string | undefined
}): TaggedPlant => {
const savedGardenId = isString(props.openedSavedGarden)
? unpackUUID(props.openedSavedGarden).remoteId
: undefined;
return isNumber(savedGardenId)
? {
kind: "PlantTemplate",
uuid: "--never",
specialStatus: SpecialStatus.SAVED,
body: {
x: props.x,
y: props.y,
z: 0,
openfarm_slug: props.slug,
name: props.cropName,
radius: DEFAULT_PLANT_RADIUS,
saved_garden_id: savedGardenId,
}
}
: {
kind: "Point",
uuid: "--never",
specialStatus: SpecialStatus.SAVED,
body: Plant({
x: props.x,
y: props.y,
openfarm_slug: props.slug,
name: props.cropName,
created_at: moment().toISOString(),
radius: DEFAULT_PLANT_RADIUS
})
};
};
export const createPlant = (props: {
cropName: string,
slug: string,
gardenCoords: AxisNumberProperty,
gridSize: AxisNumberProperty | undefined,
dispatch: Function,
openedSavedGarden: string | undefined
}) => {
const { cropName, slug, gardenCoords, gridSize, openedSavedGarden } = props;
const { x, y } = gardenCoords;
const tooLow = x < 0 || y < 0; // negative (beyond grid start)
const tooHigh = gridSize
? x > gridSize.x || y > gridSize.y // beyond grid end
: false;
const outsideGrid = tooLow || tooHigh;
if (outsideGrid) {
error(t(Content.OUTSIDE_PLANTING_AREA));
} else {
const p = newPlant({ x, y, slug, cropName, openedSavedGarden });
// Stop non-plant objects from creating generic plants in the map
if (p.body.name != "name" && p.body.openfarm_slug != "slug") {
// Create and save a new plant in the garden map
props.dispatch(initSave(p));
}
}
};
export class GardenMap extends
React.Component<GardenMapProps, Partial<GardenMapState>> {
constructor(props: GardenMapProps) {
super(props);
this.state = {};
}
/** Assemble the props needed for placement of items in the map. */
get mapTransformProps(): MapTransformProps {
return {
quadrant: this.props.botOriginQuadrant,
gridSize: this.props.gridSize,
xySwap: !!this.props.getConfigValue(BooleanSetting.xy_swap),
};
}
componentWillUnmount() {
// Clear plant selection when navigating away from the designer.
unselectPlant(this.props.dispatch)();
}
/** Currently editing a plant? */
get isEditing(): boolean { return getMode() === Mode.editPlant; }
/** Display plant animations? */
get animate(): boolean {
return !this.props.getConfigValue(BooleanSetting.disable_animations);
}
endDrag = () => {
const p = this.getPlant();
if (p && this.state.isDragging) {
// Save the new plant location
this.props.dispatch(edit(p, { x: round(p.body.x), y: round(p.body.y) }));
this.props.dispatch(save(p.uuid));
}
this.setState({
isDragging: false, pageX: 0, pageY: 0,
activeDragXY: { x: undefined, y: undefined, z: undefined },
activeDragSpread: undefined,
selectionBox: undefined
});
}
/** Fetch the current plant's spread. */
setActiveSpread(slug: string) {
const selectedPlant = this.props.selectedPlant;
const defaultSpreadCm = selectedPlant ? selectedPlant.body.radius : 0;
cachedCrop(slug)
.then(({ spread }) =>
// Convert spread diameter from cm to mm.
// `radius * 10` is the default value for spread diameter (in mm).
this.setState({ activeDragSpread: (spread || defaultSpreadCm) * 10 })
);
}
/** Get the garden map coordinate of a cursor or screen interaction. */
getGardenCoordinates(
e: React.DragEvent<HTMLElement> | React.MouseEvent<SVGElement>
): AxisNumberProperty | undefined {
const el = document.querySelector("div.drop-area svg[id='drop-area-svg']");
const map = document.querySelector(".farm-designer-map");
const page = document.querySelector(".farm-designer");
if (el && map && page) {
const zoomLvl = parseFloat(window.getComputedStyle(map).zoom || "1");
const params: ScreenToGardenParams = {
page: { x: e.pageX, y: e.pageY },
scroll: { left: page.scrollLeft, top: map.scrollTop * zoomLvl },
mapTransformProps: this.mapTransformProps,
gridOffset: this.props.gridOffset,
zoomLvl,
mapOnly: last(getPathArray()) === "designer",
};
return translateScreenToGarden(params);
} else {
return undefined;
}
}
startDrag = (e: React.MouseEvent<SVGElement>): void => {
switch (getMode()) {
case Mode.editPlant:
this.setState({ isDragging: true });
const plant = this.getPlant();
if (plant) {
this.setActiveSpread(plant.body.openfarm_slug);
}
break;
case Mode.boxSelect:
const gardenCoords = this.getGardenCoordinates(e);
if (gardenCoords) {
// Set the starting point (initial corner) of a selection box
this.setState({
selectionBox: {
x0: gardenCoords.x, y0: gardenCoords.y,
x1: undefined, y1: undefined
}
});
}
// Clear the previous plant selection when starting a new selection box
this.props.dispatch({ type: Actions.SELECT_PLANT, payload: undefined });
break;
case Mode.createPoint:
this.setState({ isDragging: true });
const center = this.getGardenCoordinates(e);
if (center) {
// Set the center of a new point
this.props.dispatch({
type: Actions.SET_CURRENT_POINT_DATA,
payload: { cx: center.x, cy: center.y, r: 0 }
});
}
break;
}
}
/** Return the selected plant, mode-allowing. */
getPlant = (): TaggedPlant | undefined => {
switch (getMode()) {
case Mode.boxSelect:
case Mode.moveTo:
case Mode.createPoint:
return undefined; // For modes without plant interaction
default:
return this.props.selectedPlant;
}
}
handleDragOver = (e: React.DragEvent<HTMLElement>) => {
switch (getMode()) {
case Mode.addPlant:
case Mode.clickToAdd:
e.preventDefault(); // Allows dragged-in plants to be placed in the map
e.dataTransfer.dropEffect = "move";
}
}
handleDragEnter = (e: React.DragEvent<HTMLElement>) => {
switch (getMode()) {
case Mode.addPlant:
e.preventDefault();
}
}
handleDrop = (
e: React.DragEvent<HTMLElement> | React.MouseEvent<SVGElement>) => {
e.preventDefault();
const gardenCoords = this.getGardenCoordinates(e);
if (gardenCoords) {
const { designer, gridSize, dispatch } = this.props;
const slug = getPathArray()[5];
const { crop } = findBySlug(designer.cropSearchResults || [], slug);
const { openedSavedGarden } = designer;
createPlant({
cropName: crop.name,
slug: crop.slug,
gardenCoords,
gridSize,
dispatch,
openedSavedGarden
});
} else {
throw new Error(`Missing 'drop-area-svg', 'farm-designer-map', or
'farm-designer' while trying to add a plant.`);
}
}
click = (e: React.MouseEvent<SVGElement>) => {
switch (getMode()) {
case Mode.clickToAdd:
// Create a new plant in the map
this.handleDrop(e);
break;
case Mode.moveTo:
e.preventDefault();
const gardenCoords = this.getGardenCoordinates(e);
if (gardenCoords) {
// Mark a new bot target location on the map
this.props.dispatch({
type: Actions.CHOOSE_LOCATION,
payload: { x: gardenCoords.x, y: gardenCoords.y, z: 0 }
});
}
break;
}
}
/** Return all plants within the selection box. */
getSelected(box: SelectionBoxData) {
const selected = this.props.plants.filter(p => {
if (box &&
isNumber(box.x0) && isNumber(box.y0) &&
isNumber(box.x1) && isNumber(box.y1)) {
return (
p.body.x >= Math.min(box.x0, box.x1) &&
p.body.x <= Math.max(box.x0, box.x1) &&
p.body.y >= Math.min(box.y0, box.y1) &&
p.body.y <= Math.max(box.y0, box.y1)
);
}
}).map(p => { return p.uuid; });
return selected.length > 0 ? selected : undefined;
}
// tslint:disable-next-line:cyclomatic-complexity
drag = (e: React.MouseEvent<SVGElement>) => {
switch (getMode()) {
case Mode.editPlant:
const plant = this.getPlant();
const map = document.querySelector(".farm-designer-map");
const { gridSize } = this.props;
const { quadrant, xySwap } = this.mapTransformProps;
if (this.state.isDragging && plant && map) {
const zoomLvl = parseFloat(window.getComputedStyle(map).zoom || "1");
const { qx, qy } = transformXY(e.pageX, e.pageY, this.mapTransformProps);
const deltaX = Math.round((qx - (this.state.pageX || qx)) / zoomLvl);
const deltaY = Math.round((qy - (this.state.pageY || qy)) / zoomLvl);
const dX = xySwap && (quadrant % 2 === 1) ? -deltaX : deltaX;
const dY = xySwap && (quadrant % 2 === 1) ? -deltaY : deltaY;
this.setState({
pageX: qx, pageY: qy,
activeDragXY: { x: plant.body.x + dX, y: plant.body.y + dY, z: 0 }
});
this.props.dispatch(movePlant({ deltaX: dX, deltaY: dY, plant, gridSize }));
}
break;
case Mode.boxSelect:
if (this.state.selectionBox) {
const current = this.getGardenCoordinates(e);
if (current) {
const { x0, y0 } = this.state.selectionBox;
this.setState({
selectionBox: {
x0, y0, // Keep box starting corner
x1: current.x, y1: current.y // Update box active corner
}
});
// Select all plants within the updated selection box
this.props.dispatch({
type: Actions.SELECT_PLANT,
payload: this.getSelected(this.state.selectionBox)
});
}
}
break;
case Mode.createPoint:
const edge = this.getGardenCoordinates(e);
const { currentPoint } = this.props.designer;
if (edge && currentPoint && !!this.state.isDragging) {
const { cx, cy } = currentPoint;
// Adjust the radius of the point being created
this.props.dispatch({
type: Actions.SET_CURRENT_POINT_DATA,
payload: {
cx, cy, // Center was set by click, radius is adjusted by drag
r: Math.round(Math.sqrt(
Math.pow(edge.x - cx, 2) + Math.pow(edge.y - cy, 2))),
}
});
}
break;
}
}
render() {
const { gridSize } = this.props;
const mapTransformProps = this.mapTransformProps;
const mapSize = getMapSize(mapTransformProps, this.props.gridOffset);
const { xySwap } = mapTransformProps;
return <div
className="drop-area"
style={{
height: mapSize.h + "px", maxHeight: mapSize.h + "px",
width: mapSize.w + "px", maxWidth: mapSize.w + "px"
}}
onDrop={this.handleDrop}
onDragEnter={this.handleDragEnter}
onDragOver={this.handleDragOver}
onMouseLeave={this.endDrag}
onMouseUp={this.endDrag}
onDragEnd={this.endDrag}
onDragStart={(e) => e.preventDefault()}>
<svg
id="map-background-svg">
<MapBackground
templateView={!!this.props.designer.openedSavedGarden}
mapTransformProps={mapTransformProps}
plantAreaOffset={this.props.gridOffset} />
<svg
className="drop-area-svg"
id="drop-area-svg"
x={this.props.gridOffset.x} y={this.props.gridOffset.y}
width={xySwap ? gridSize.y : gridSize.x}
height={xySwap ? gridSize.x : gridSize.y}
onMouseUp={this.endDrag}
onMouseDown={this.startDrag}
onMouseMove={this.drag}
onClick={this.click}>
<ImageLayer
images={this.props.latestImages}
cameraCalibrationData={this.props.cameraCalibrationData}
visible={!!this.props.showImages}
mapTransformProps={mapTransformProps}
getConfigValue={this.props.getConfigValue} />
<Grid
onClick={closePlantInfo(this.props.dispatch)}
mapTransformProps={mapTransformProps} />
<SensorReadingsLayer
visible={!!this.props.showSensorReadings}
sensorReadings={this.props.sensorReadings}
mapTransformProps={mapTransformProps}
timeOffset={this.props.timeOffset}
sensors={this.props.sensors} />
<SpreadLayer
mapTransformProps={mapTransformProps}
plants={this.props.plants}
currentPlant={this.getPlant()}
visible={!!this.props.showSpread}
dragging={!!this.state.isDragging}
zoomLvl={this.props.zoomLvl}
activeDragXY={this.state.activeDragXY}
activeDragSpread={this.state.activeDragSpread}
editing={this.isEditing}
animate={this.animate} />
<PointLayer
mapTransformProps={mapTransformProps}
visible={!!this.props.showPoints}
points={this.props.points} />
<PlantLayer
mapTransformProps={mapTransformProps}
dispatch={this.props.dispatch}
visible={!!this.props.showPlants}
plants={this.props.plants}
crops={this.props.crops}
currentPlant={this.getPlant()}
dragging={!!this.state.isDragging}
editing={this.isEditing}
selectedForDel={this.props.designer.selectedPlants}
zoomLvl={this.props.zoomLvl}
activeDragXY={this.state.activeDragXY}
animate={this.animate} />
<ToolSlotLayer
mapTransformProps={mapTransformProps}
visible={!!this.props.showFarmbot}
slots={this.props.toolSlots} />
<FarmBotLayer
mapTransformProps={mapTransformProps}
visible={!!this.props.showFarmbot}
botLocationData={this.props.botLocationData}
stopAtHome={this.props.stopAtHome}
botSize={this.props.botSize}
plantAreaOffset={this.props.gridOffset}
peripherals={this.props.peripherals}
eStopStatus={this.props.eStopStatus}
getConfigValue={this.props.getConfigValue} />
<HoveredPlantLayer
visible={!!this.props.showPlants}
isEditing={this.isEditing}
mapTransformProps={mapTransformProps}
currentPlant={this.getPlant()}
designer={this.props.designer}
hoveredPlant={this.props.hoveredPlant}
dragging={!!this.state.isDragging}
animate={this.animate} />
<DragHelperLayer
mapTransformProps={mapTransformProps}
currentPlant={this.getPlant()}
dragging={!!this.state.isDragging}
editing={this.isEditing}
zoomLvl={this.props.zoomLvl}
activeDragXY={this.state.activeDragXY}
plantAreaOffset={this.props.gridOffset} />
{this.state.selectionBox &&
<SelectionBox
selectionBox={this.state.selectionBox}
mapTransformProps={mapTransformProps} />}
{this.props.designer.chosenLocation &&
<TargetCoordinate
chosenLocation={this.props.designer.chosenLocation}
mapTransformProps={mapTransformProps} />}
{this.props.designer.currentPoint &&
<DrawnPoint
data={this.props.designer.currentPoint}
key={"currentPoint"}
mapTransformProps={mapTransformProps} />}
{showBugs() && <Bugs mapTransformProps={mapTransformProps}
botSize={this.props.botSize} />}
</svg>
</svg>
</div>;
}
}