frontend/farm_designer/map/util.ts
import { BotOriginQuadrant } from "../interfaces";
import { McuParams, Xyz } from "farmbot";
import { StepsPerMm } from "../../devices/interfaces";
import {
CheckedAxisLength, AxisNumberProperty, BotSize, MapTransformProps, Mode,
TaggedPlant,
} from "./interfaces";
import { trim } from "../../util";
import { store } from "../../redux/store";
import { Path } from "../../internal_urls";
import { isMobile } from "../../screen_size";
/*
* Farm Designer Map Utilities
*
* Terms and Definitions:
* GARDEN coordinates: real coordinates (could be sent to bot)
* MAP coordinates: displayed locations (transformed according to map)
*
* Example:
* garden coordinate of {x: 100, y: 100}
* would be displayed at {x: 300, y: 300}
* if the bot axis sizes are {x: 400, y: 400}
* and the origin is in the lower right (map quadrant)
*/
/** multiple to round to for round() */
const SNAP = 10;
/**
* Used for snapping.
* Rounds units to nearest 10 (or whatever SNAP is set to).
*/
export function round(num: number) {
return (Math.round(num / SNAP) * SNAP);
}
/*
* Map coordinate calculations
*
* Constant:
* left and top map padding
* Map grid offset (for now)
*
* Dynamic:
* mouse position
* left and top scroll
* zoom level
* quadrant
* grid size
* XY swap
*
*/
/** Status of farm designer side panel. */
export enum MapPanelStatus {
open = "open",
mobileClosed = "mobileClosed",
closed = "closed",
short = "short",
}
/** Get farm designer side panel status. */
export const getPanelStatus = (): MapPanelStatus => {
if (Path.equals(Path.designer())) {
return isMobile() ? MapPanelStatus.mobileClosed : MapPanelStatus.closed;
}
const mode = getMode();
if (isMobile() &&
(mode === Mode.locationInfo ||
mode === Mode.clickToAdd)) {
return MapPanelStatus.short;
}
return MapPanelStatus.open;
};
/** Get panel status class name for farm designer. */
export const mapPanelClassName = () => {
switch (getPanelStatus()) {
case MapPanelStatus.short: return "short-panel";
case MapPanelStatus.mobileClosed: return "panel-closed-mobile";
case MapPanelStatus.closed: return "panel-closed";
case MapPanelStatus.open:
default:
return "panel-open";
}
};
/** Controlled by .farm-designer-map padding x10 */
export const getMapPadding =
(panelStatus: MapPanelStatus): { left: number, top: number } => {
switch (panelStatus) {
case MapPanelStatus.short: return { left: 20, top: 350 };
case MapPanelStatus.mobileClosed: return { left: 20, top: 160 };
case MapPanelStatus.closed: return { left: 20, top: 110 };
case MapPanelStatus.open:
default:
return { left: 468, top: 110 };
}
};
/** "x" => "left" and "y" => "top" */
const leftOrTop: Record<"x" | "y", "top" | "left"> = { x: "left", y: "top" };
type XYCoordinate = { x: number, y: number };
interface ScreenToGardenParams {
page: XYCoordinate;
scroll: { left: number, top: number };
zoomLvl: number;
mapTransformProps: MapTransformProps;
gridOffset: AxisNumberProperty;
panelStatus: MapPanelStatus;
}
/** Transform screen coordinates into garden coordinates */
export function translateScreenToGarden(
params: ScreenToGardenParams,
): XYCoordinate {
const {
page, scroll, zoomLvl, mapTransformProps, gridOffset, panelStatus
} = params;
const { xySwap } = mapTransformProps;
const mapPadding = getMapPadding(panelStatus);
const screenXY = page;
const mapXY = ["x", "y"].reduce<XYCoordinate>(
(result: XYCoordinate, axis: "x" | "y") => {
const unscrolled = screenXY[axis] + scroll[leftOrTop[axis]];
const map = unscrolled - mapPadding[leftOrTop[axis]];
const grid = map - gridOffset[axis] * zoomLvl;
const unscaled = round(grid / zoomLvl);
result[axis] = unscaled;
return result;
}, { x: 0, y: 0 });
const coordinate = xySwap ? { x: mapXY.y, y: mapXY.x } : mapXY;
const gardenXY = transformXY(coordinate.x, coordinate.y, mapTransformProps);
return xySwap
? { x: gardenXY.qy, y: gardenXY.qx }
: { x: gardenXY.qx, y: gardenXY.qy };
}
/* BotOriginQuadrant diagram
2 --- 1
| |
3 --- 4
*/
const NORMAL_QUADRANTS: Record<"x" | "y", BotOriginQuadrant[]> = {
// `2` is shared, i.e. no change needed for either axis when it is selected
x: [3, 2],
y: [2, 1]
};
const MIRRORED_QUADRANTS: Record<"x" | "y", BotOriginQuadrant[]> = {
// `4` is shared, i.e. change needed for both axes when it is selected
x: [1, 4],
y: [4, 3]
};
interface QuadTransformParams {
/** garden or map coordinates */
coordinate: XYCoordinate;
/** props necessary for coordinate transformation */
mapTransformProps: MapTransformProps;
}
/** Quadrant coordinate transformation */
function quadTransform(params: QuadTransformParams): XYCoordinate {
const { coordinate, mapTransformProps } = params;
const { gridSize, quadrant } = mapTransformProps;
return ["x", "y"].reduce<XYCoordinate>(
(result: XYCoordinate, axis: "x" | "y") => {
switch (quadrant) {
case MIRRORED_QUADRANTS[axis][0]:
case MIRRORED_QUADRANTS[axis][1]:
result[axis] = gridSize[axis] - coordinate[axis];
return result;
default:
case NORMAL_QUADRANTS[axis][0]:
case NORMAL_QUADRANTS[axis][1]:
result[axis] = coordinate[axis];
return result;
}
}, { x: 0, y: 0 });
}
/**
* Transform between garden and map coordinates
*
* Used for placing things in the Farm Designer map
* or getting the real coordinates of things in the map.
*
* @param coordinate: garden or map coordinate
* @param mapTransformProps: props necessary for coordinate transformation
*/
export function transformXY(
x: number,
y: number,
rawMapTransformProps: MapTransformProps,
): { qx: number, qy: number } {
const { quadrant, gridSize, xySwap } = rawMapTransformProps;
const coordinate = {
x: xySwap ? y : x,
y: xySwap ? x : y,
};
const transformed = quadTransform({
coordinate,
mapTransformProps: {
quadrant,
gridSize: {
x: xySwap ? gridSize.y : gridSize.x,
y: xySwap ? gridSize.x : gridSize.y,
},
xySwap
}
});
return {
qx: transformed.x,
qy: transformed.y
};
}
/** Determine bot axis lengths according to firmware settings */
export function getBotSize(
botMcuParams: McuParams,
stepsPerMm: StepsPerMm,
defaultLength: Record<Xyz, number>,
): BotSize {
const stopAtMaxXY = {
x: !!botMcuParams.movement_stop_at_max_x,
y: !!botMcuParams.movement_stop_at_max_y,
z: !!botMcuParams.movement_stop_at_max_z,
};
const axisLengthXY = {
x: botMcuParams.movement_axis_nr_steps_x || 0,
y: botMcuParams.movement_axis_nr_steps_y || 0,
z: botMcuParams.movement_axis_nr_steps_z || 0,
};
const getAxisLength = (axis: "x" | "y" | "z"): CheckedAxisLength => {
const axisStepsPerMm = stepsPerMm[axis];
if (axisStepsPerMm && axisLengthXY[axis] !== 0 && stopAtMaxXY[axis]) {
return { value: axisLengthXY[axis] / axisStepsPerMm, isDefault: false };
} else {
return { value: defaultLength[axis], isDefault: true };
}
};
return { x: getAxisLength("x"), y: getAxisLength("y"), z: getAxisLength("z") };
}
/** Calculate map dimensions */
export function getMapSize(
mapTransformProps: MapTransformProps,
gridOffset?: AxisNumberProperty,
): { w: number, h: number } {
const { gridSize, xySwap } = mapTransformProps;
const mapSize = {
x: gridSize.x + (gridOffset?.x || 0) * 2,
y: gridSize.y + (gridOffset?.y || 0) * 2
};
return {
w: xySwap ? mapSize.y : mapSize.x,
h: xySwap ? mapSize.x : mapSize.y
};
}
/** Transform object based on selected map quadrant and grid size. */
export const transformForQuadrant =
(mapTransformProps: MapTransformProps): string => {
const quadrantFlips = () => {
switch (mapTransformProps.quadrant) {
case BotOriginQuadrant.ONE: return { x: -1, y: 1 };
case BotOriginQuadrant.TWO: return { x: 1, y: 1 };
case BotOriginQuadrant.THREE: return { x: 1, y: -1 };
case BotOriginQuadrant.FOUR: return { x: -1, y: -1 };
}
};
const origin = transformXY(0, 0, mapTransformProps);
const flip = quadrantFlips();
const translate = { x: flip.x * origin.qx, y: flip.y * origin.qy };
return trim(
`scale(${flip.x}, ${flip.y})
translate(${translate.x}, ${translate.y})`);
};
/** Determine the current map mode based on path. */
// eslint-disable-next-line complexity
export const getMode = (): Mode => {
if (store.getState().resources.consumers.farm_designer.profileOpen) {
return Mode.profile;
}
const panelSlug = Path.getSlug(Path.designer());
if ((panelSlug === "groups" || panelSlug === "zones")
&& Path.getSlug(Path.groups())) { return Mode.editGroup; }
if (Path.getSlug(Path.cropSearch("plant")) === "add") {
return Mode.clickToAdd;
}
if (savedGardenOpen()) { return Mode.templateView; }
if (Path.lastChunkIsNum()) { return Mode.editPlant; }
if (Path.getSlug(Path.plants()) === "select") {
return Mode.boxSelect;
}
if (Path.getSlug(Path.plants()) === "crop_search" &&
Path.getSlug(Path.cropSearch())) { return Mode.clickToAdd; }
if (panelSlug === "location") { return Mode.locationInfo; }
if (panelSlug === "points") {
if (Path.getSlug(Path.points()) === "add") {
return Mode.createPoint;
}
return Mode.points;
}
if (Path.getSlug(Path.designer()) === "weeds") {
if (Path.getSlug(Path.weeds()) === "add") {
return Mode.createWeed;
}
return Mode.weeds;
}
return Mode.none;
};
/** Check if a SavedGarden is currently open (URL approach). */
export const savedGardenOpen = () =>
Path.getSlug(Path.designer()) === "gardens" &&
parseInt(Path.getSlug(Path.savedGardens())) > 0
? parseInt(Path.getSlug(Path.savedGardens()))
: false;
const getZoomLevelFromMap = (map: Element) =>
parseFloat((window.getComputedStyle(map).transform || "(1").split("(")[1]);
/** Get the garden map coordinate of a cursor or screen interaction. */
export const getGardenCoordinates = (props: {
mapTransformProps: MapTransformProps,
gridOffset: AxisNumberProperty,
pageX: number,
pageY: number,
}): AxisNumberProperty | undefined => {
const el = document.querySelector(".drop-area-svg");
const map = document.querySelector(".farm-designer-map");
const page = document.querySelector(".farm-designer");
if (el && map && page) {
const zoomLvl = getZoomLevelFromMap(map);
const params: ScreenToGardenParams = {
page: { x: props.pageX, y: props.pageY },
scroll: { left: page.scrollLeft, top: page.scrollTop },
mapTransformProps: props.mapTransformProps,
gridOffset: props.gridOffset,
zoomLvl,
panelStatus: getPanelStatus(),
};
return translateScreenToGarden(params);
} else {
return undefined;
}
};
export const allowInteraction = () => {
switch (getMode()) {
case Mode.clickToAdd:
case Mode.locationInfo:
case Mode.createPoint:
case Mode.createWeed:
case Mode.profile:
return false;
default:
return true;
}
};
export const allowGroupAreaInteraction = () => {
if (!allowInteraction()) { return false; }
switch (getMode()) {
case Mode.boxSelect:
case Mode.editGroup:
return false;
default:
return true;
}
};
/** Check if the cursor is within the selected plant indicator area. */
export const cursorAtPlant =
(plant: TaggedPlant | undefined, cursor: AxisNumberProperty | undefined) =>
plant && cursor
&& (cursor.x > plant.body.x - plant.body.radius * 1.2)
&& (cursor.y > plant.body.y - plant.body.radius * 1.2)
&& (cursor.x < plant.body.x + plant.body.radius * 1.2)
&& (cursor.y < plant.body.y + plant.body.radius * 1.2);
/** Scale icon within radius circle.
*
* | _/
* | _/ UPPER_FACTOR * radius (20% shown)
* MID | ______/
* | /
* | / LOWER_FACTOR * radius (80% shown)
* | /
* MIN |---'
* |____________________
* 0 ^ ^ ^
* | | LARGE
* | |
* | MID / LOWER_FACTOR
* |
* MIN / LOWER_FACTOR
*/
export const scaleIcon = (radius: number): number => {
const MIN = 10;
const MID = 30;
const LARGE = 150;
const LOWER_FACTOR = 0.8;
const UPPER_FACTOR = 0.2;
if (radius > LARGE) { return round(UPPER_FACTOR * radius); }
if (radius < MIN / LOWER_FACTOR) { return MIN; }
if (radius < MID / LOWER_FACTOR) { return round(LOWER_FACTOR * radius); }
return MID;
};
/** Calculate default plant spread value (diameter in centimeters). */
export const defaultSpreadCmDia = (radius: number) =>
Math.max(25, round(0.2 * radius));