FarmBot/Farmbot-Web-App

View on GitHub
frontend/farm_designer/map/layers/points/interpolation_map.tsx

Summary

Maintainability
B
5 hrs
Test Coverage
import React from "react";
import {
  TaggedFarmwareEnv,
  TaggedGenericPointer, TaggedPoint, TaggedSensorReading, Xyz,
} from "farmbot";
import { MapTransformProps } from "../../interfaces";
import { transformXY } from "../../util";
import { isUndefined, range, round, sum } from "lodash";
import { distance, findNearest } from "../../../../point_groups/paths";
import { selectMostRecentPoints } from "../../../location_info";
import { betterCompact } from "../../../../util";
import { t } from "../../../../i18next_wrapper";
import { BlurableInput, ToggleButton } from "../../../../ui";
import {
  getModifiedClassNameSpecifyDefault,
} from "../../../../settings/default_values";
import { SaveFarmwareEnv } from "../../../../farmware/interfaces";

export enum InterpolationKey {
  data = "interpolationData",
  hash = "interpolationHash",
  opts = "interpolationOpts",
}

enum MoistureInterpolationKey {
  data = "interpolationDataMoisture",
  hash = "interpolationHashMoisture",
}

export type InterpolationData = Record<Xyz, number>[];

export const getInterpolationData =
  (kind: "Point" | "SensorReading"): InterpolationData =>
    JSON.parse(localStorage.getItem((kind == "SensorReading"
      ? MoistureInterpolationKey
      : InterpolationKey).data) || "[]");

interface InterpolationOptions {
  stepSize: number;
  useNearest: boolean;
  power: number;
}

export const DEFAULT_INTERPOLATION_OPTIONS: InterpolationOptions = {
  stepSize: 50,
  useNearest: false,
  power: 4,
};

export const fetchInterpolationOptions =
  (farmwareEnvs: TaggedFarmwareEnv[]): InterpolationOptions => {
    const getValue = getOptionValue(farmwareEnvs);
    const options: InterpolationOptions = {
      stepSize: getValue(InterpolationOption.stepSize,
        DEFAULT_INTERPOLATION_OPTIONS.stepSize),
      useNearest: getValue(InterpolationOption.useNearest, 0) == 1,
      power: getValue(InterpolationOption.power,
        DEFAULT_INTERPOLATION_OPTIONS.power),
    };
    return options;
  };

export interface GetZAtLocationProps {
  x: number | undefined;
  y: number | undefined;
  farmwareEnvs: TaggedFarmwareEnv[] | undefined;
  points: TaggedGenericPointer[] | undefined;
}

export const getZAtLocation =
  (props: GetZAtLocationProps) => {
    const { x, y, farmwareEnvs, points } = props;
    if (isUndefined(x) || isUndefined(y)
      || isUndefined(farmwareEnvs) || isUndefined(points)) { return; }
    const options = fetchInterpolationOptions(farmwareEnvs);
    const interpolationPoints = selectMostRecentPoints(points);
    return interpolatedZ({ x, y }, interpolationPoints, options);
  };

interface GenerateInterpolationMapDataProps {
  kind: "Point" | "SensorReading";
  points: (TaggedGenericPointer | TaggedSensorReading)[];
  mapTransformProps: MapTransformProps;
  getColor(z: number): string;
  options: InterpolationOptions;
}

interface PointObject {
  uuid: string;
  x: number;
  y: number;
  value: number;
}

const convertToPointObject =
  (point: TaggedPoint | TaggedSensorReading): PointObject | undefined =>
    !isUndefined(point.body.x) && !isUndefined(point.body.y)
      ? ({
        uuid: point.uuid,
        x: point.body.x, y: point.body.y,
        value: point.kind == "SensorReading" ? point.body.value : point.body.z,
      })
      : undefined;

export const generateData = (props: GenerateInterpolationMapDataProps) => {
  const points = selectMostRecentPoints(props.points);
  const { gridSize } = props.mapTransformProps;
  const { stepSize } = props.options;
  const hash = [
    JSON.stringify(points),
    JSON.stringify(gridSize),
    JSON.stringify(props.options),
  ].join("");
  const Key = props.kind == "SensorReading"
    ? MoistureInterpolationKey
    : InterpolationKey;
  if (localStorage.getItem(Key.hash) == hash) { return; }
  const data: InterpolationData = [];
  range(0, gridSize.x, stepSize).map(x =>
    range(0, gridSize.y, stepSize).map(y => {
      const z = interpolatedZ({ x, y }, points, props.options);
      if (!isUndefined(z)) { data.push({ x, y, z }); }
    }));
  localStorage.setItem(Key.data, JSON.stringify(data));
  localStorage.setItem(Key.hash, hash);
};

export const interpolatedZ = (
  position: { x: number, y: number },
  points: (TaggedPoint | TaggedSensorReading)[],
  options: InterpolationOptions,
) => {
  const { useNearest, power } = options;
  const nearest = findNearest(position, points);
  if (!nearest || isUndefined(nearest.body.x) || isUndefined(nearest.body.y)) {
    return undefined;
  }
  if (distance(position, { x: nearest.body.x, y: nearest.body.y }) == 0
    || useNearest) {
    return nearest.kind == "SensorReading" ? nearest.body.value : nearest.body.z;
  }
  const pointObjects = betterCompact(points.map(convertToPointObject));
  return round(
    weightedSum(position, pointObjects, power, true)
    / weightedSum(position, pointObjects, power),
    2);
};

const weightedSum = (
  position: { x: number, y: number },
  points: PointObject[],
  power: number,
  withZ = false,
) =>
  sum(points.map(point =>
    (1 / distance(position, point) ** power)
    * (withZ ? point.value : 1)));

interface InterpolationMapProps {
  kind: "Point" | "SensorReading";
  points: (TaggedGenericPointer | TaggedSensorReading)[];
  mapTransformProps: MapTransformProps;
  getColor(z: number): string;
  options: InterpolationOptions;
}

export const InterpolationMap = (props: InterpolationMapProps) => {
  const step = props.options.stepSize;
  return <g id={"interpolation-map"} style={{ pointerEvents: "none" }}>
    <g id={"map-tiles"} clipPath={"url(#map-grid-clip-path)"}>
      {getInterpolationData(props.kind).map(p => {
        const { x, y, z } = p;
        const { qx, qy } = transformXY(x, y, props.mapTransformProps);
        const { quadrant } = props.mapTransformProps;
        const xOffset = [1, 4].includes(quadrant);
        const yOffset = [3, 4].includes(quadrant);
        return <rect key={`${x}-${y}`}
          x={qx - (xOffset ? step : 0)}
          y={qy - (yOffset ? step : 0)}
          width={step} height={step}
          fill={props.getColor(z)} fillOpacity={0.75} />;
      })}
    </g>
  </g>;
};

interface InterpolationSettingsProps {
  dispatch: Function;
  farmwareEnvs: TaggedFarmwareEnv[];
  saveFarmwareEnv: SaveFarmwareEnv;
}

export enum InterpolationOption {
  stepSize = "interpolation_step_size",
  power = "interpolation_power",
  useNearest = "interpolation_use_nearest",
}

export const InterpolationSettings = (props: InterpolationSettingsProps) => {
  const { dispatch, farmwareEnvs, saveFarmwareEnv } = props;
  const common = { dispatch, farmwareEnvs, saveFarmwareEnv };
  return <div className={"interpolation-settings"}>
    <InterpolationSetting {...common}
      label={t("Interpolation step size")}
      optKey={InterpolationOption.stepSize}
      min={25}
      max={500}
      defaultValue={DEFAULT_INTERPOLATION_OPTIONS.stepSize} />
    <InterpolationSetting {...common}
      label={t("Interpolation weight")}
      optKey={InterpolationOption.power}
      min={2}
      max={32}
      defaultValue={DEFAULT_INTERPOLATION_OPTIONS.power} />
    <InterpolationSetting {...common}
      boolean={true}
      label={t("Interpolation use nearest")}
      optKey={InterpolationOption.useNearest}
      defaultValue={0} />
  </div>;
};

export interface InterpolationSettingProps {
  label: string;
  optKey: string;
  min?: number;
  max?: number;
  boolean?: boolean;
  defaultValue: number;
  farmwareEnvs: TaggedFarmwareEnv[];
  saveFarmwareEnv: SaveFarmwareEnv;
  dispatch: Function;
}

const getOptionValue = (farmwareEnvs: TaggedFarmwareEnv[]) =>
  (key: string, defaultValue: number) => {
    const envValue = farmwareEnvs.filter(farmwareEnv =>
      farmwareEnv.body.key == key)[0]?.body.value;
    return parseInt(envValue ? ("" + envValue) : ("" + defaultValue));
  };

export const InterpolationSetting = (props: InterpolationSettingProps) => {
  const value = getOptionValue(props.farmwareEnvs)(
    props.optKey, props.defaultValue);
  return <div className={"camera-config-number-box"}>
    <label>
      {t(props.label)}
    </label>
    {props.boolean
      ? <ToggleButton
        className={getModifiedClassNameSpecifyDefault(value, props.defaultValue)}
        toggleValue={value}
        toggleAction={() => props.dispatch(props.saveFarmwareEnv(
          props.optKey, value == 1 ? "0" : "1"))} />
      : <BlurableInput type="number"
        className={getModifiedClassNameSpecifyDefault(value, props.defaultValue)}
        value={value}
        min={props.min}
        max={props.max}
        onCommit={e => props.dispatch(props.saveFarmwareEnv(
          props.optKey, e.currentTarget.value))} />}
  </div>;
};