add sort comparison UI
parent
7d3a525847
commit
71bb473a1a
|
@ -13,4 +13,5 @@ export const fakeDesignerState = (): DesignerState => ({
|
|||
chosenLocation: { x: undefined, y: undefined, z: undefined },
|
||||
currentPoint: undefined,
|
||||
openedSavedGarden: undefined,
|
||||
tryGroupSortType: undefined,
|
||||
});
|
||||
|
|
|
@ -974,6 +974,7 @@ export enum Actions {
|
|||
CHOOSE_LOCATION = "CHOOSE_LOCATION",
|
||||
SET_CURRENT_POINT_DATA = "SET_CURRENT_POINT_DATA",
|
||||
CHOOSE_SAVED_GARDEN = "CHOOSE_SAVED_GARDEN",
|
||||
TRY_SORT_TYPE = "TRY_SORT_TYPE",
|
||||
|
||||
// Regimens
|
||||
PUSH_WEEK = "PUSH_WEEK",
|
||||
|
|
|
@ -1469,3 +1469,16 @@ textarea {
|
|||
textarea:focus {
|
||||
box-shadow: 0 0 10px rgba(0,0,0,.2);
|
||||
}
|
||||
|
||||
.sort-path-info-bar {
|
||||
background: lightgray;
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
white-space: nowrap;
|
||||
line-height: 1.75rem;
|
||||
&:hover {
|
||||
background: darken(lightgray, 10%);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
import { BotPosition } from "../../devices/interfaces";
|
||||
import { fakeCropLiveSearchResult } from "../../__test_support__/fake_crop_search_result";
|
||||
import { fakeDesignerState } from "../../__test_support__/fake_designer_state";
|
||||
import { PointGroupSortType } from "farmbot/dist/resources/api_resources";
|
||||
|
||||
describe("designer reducer", () => {
|
||||
const oldState = fakeDesignerState;
|
||||
|
@ -112,4 +113,14 @@ describe("designer reducer", () => {
|
|||
const newState = designer(state, action);
|
||||
expect(newState.cropSearchInProgress).toEqual(false);
|
||||
});
|
||||
|
||||
it("starts group sort type trial", () => {
|
||||
const state = oldState();
|
||||
state.tryGroupSortType = undefined;
|
||||
const action: ReduxAction<PointGroupSortType | undefined> = {
|
||||
type: Actions.TRY_SORT_TYPE, payload: "random"
|
||||
};
|
||||
const newState = designer(state, action);
|
||||
expect(newState.tryGroupSortType).toEqual("random");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -20,7 +20,7 @@ import { AxisNumberProperty, BotSize, TaggedPlant } from "./map/interfaces";
|
|||
import { SelectionBoxData } from "./map/background";
|
||||
import { GetWebAppConfigValue } from "../config_storage/actions";
|
||||
import {
|
||||
ExecutableType, PlantPointer
|
||||
ExecutableType, PlantPointer, PointGroupSortType
|
||||
} from "farmbot/dist/resources/api_resources";
|
||||
import { BooleanConfigKey } from "farmbot/dist/resources/configs/web_app";
|
||||
import { TimeSettings } from "../interfaces";
|
||||
|
@ -108,6 +108,7 @@ export interface DesignerState {
|
|||
chosenLocation: BotPosition;
|
||||
currentPoint: CurrentPointPayl | undefined;
|
||||
openedSavedGarden: string | undefined;
|
||||
tryGroupSortType: PointGroupSortType | "nn" | undefined;
|
||||
}
|
||||
|
||||
export type TaggedExecutable = TaggedSequence | TaggedRegimen;
|
||||
|
|
|
@ -28,6 +28,7 @@ import {
|
|||
} from "./layers/plants/plant_actions";
|
||||
import { chooseLocation } from "../move_to";
|
||||
import { GroupOrder } from "../point_groups/group_order_visual";
|
||||
import { NNPath } from "../point_groups/paths";
|
||||
|
||||
export class GardenMap extends
|
||||
React.Component<GardenMapProps, Partial<GardenMapState>> {
|
||||
|
@ -347,6 +348,8 @@ export class GardenMap extends
|
|||
GroupOrder = () => <GroupOrder
|
||||
plants={this.props.plants}
|
||||
mapTransformProps={this.mapTransformProps} />
|
||||
NNPath = () => <NNPath plants={this.props.plants}
|
||||
mapTransformProps={this.mapTransformProps} />
|
||||
Bugs = () => showBugs() ? <Bugs mapTransformProps={this.mapTransformProps}
|
||||
botSize={this.props.botSize} /> : <g />
|
||||
|
||||
|
@ -370,6 +373,7 @@ export class GardenMap extends
|
|||
<this.TargetCoordinate />
|
||||
<this.DrawnPoint />
|
||||
<this.GroupOrder />
|
||||
<this.NNPath />
|
||||
<this.Bugs />
|
||||
</svg>
|
||||
</svg>
|
||||
|
|
|
@ -8,6 +8,13 @@ jest.mock("../../actions", () => ({
|
|||
toggleHoveredPlant: jest.fn()
|
||||
}));
|
||||
|
||||
let mockDev = false;
|
||||
jest.mock("../../../account/dev/dev_support", () => ({
|
||||
DevSettings: {
|
||||
futureFeaturesEnabled: () => mockDev,
|
||||
}
|
||||
}));
|
||||
|
||||
import React from "react";
|
||||
import { GroupDetailActive } from "../group_detail_active";
|
||||
import { mount, shallow } from "enzyme";
|
||||
|
@ -81,4 +88,19 @@ describe("<GroupDetailActive/>", () => {
|
|||
el.componentWillUnmount && el.componentWillUnmount();
|
||||
expect(clearInterval).toHaveBeenCalledWith(123);
|
||||
});
|
||||
|
||||
it("shows paths", () => {
|
||||
mockDev = true;
|
||||
const p = fakeProps();
|
||||
p.plants = [fakePlant(), fakePlant()];
|
||||
const wrapper = mount(<GroupDetailActive {...p} />);
|
||||
expect(wrapper.text().toLowerCase()).toContain("optimized");
|
||||
});
|
||||
|
||||
it("doesn't show paths", () => {
|
||||
mockDev = false;
|
||||
const p = fakeProps();
|
||||
const wrapper = mount(<GroupDetailActive {...p} />);
|
||||
expect(wrapper.text().toLowerCase()).not.toContain("optimized");
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,94 @@
|
|||
jest.mock("../../../api/crud", () => ({ edit: jest.fn() }));
|
||||
|
||||
import * as React from "react";
|
||||
import { mount, shallow } from "enzyme";
|
||||
import { PathInfoBar, nn, NNPath, PathInfoBarProps } from "../paths";
|
||||
import {
|
||||
fakePlant, fakePointGroup
|
||||
} from "../../../__test_support__/fake_state/resources";
|
||||
import {
|
||||
fakeMapTransformProps
|
||||
} from "../../../__test_support__/map_transform_props";
|
||||
import { Actions } from "../../../constants";
|
||||
import { edit } from "../../../api/crud";
|
||||
import { error } from "../../../toast/toast";
|
||||
|
||||
describe("<PathInfoBar />", () => {
|
||||
const fakeProps = (): PathInfoBarProps => ({
|
||||
sortTypeKey: "random",
|
||||
dispatch: jest.fn(),
|
||||
group: fakePointGroup(),
|
||||
pathData: { random: 123 },
|
||||
});
|
||||
|
||||
it("hovers path", () => {
|
||||
const p = fakeProps();
|
||||
const wrapper = shallow(<PathInfoBar {...p} />);
|
||||
wrapper.simulate("mouseEnter");
|
||||
expect(p.dispatch).toHaveBeenCalledWith({
|
||||
type: Actions.TRY_SORT_TYPE, payload: "random"
|
||||
});
|
||||
});
|
||||
|
||||
it("unhovers path", () => {
|
||||
const p = fakeProps();
|
||||
const wrapper = shallow(<PathInfoBar {...p} />);
|
||||
wrapper.simulate("mouseLeave");
|
||||
expect(p.dispatch).toHaveBeenCalledWith({
|
||||
type: Actions.TRY_SORT_TYPE, payload: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it("selects path", () => {
|
||||
const p = fakeProps();
|
||||
const wrapper = shallow(<PathInfoBar {...p} />);
|
||||
wrapper.simulate("click");
|
||||
expect(edit).toHaveBeenCalledWith(p.group, { sort_type: "random" });
|
||||
});
|
||||
|
||||
it("selects new path", () => {
|
||||
const p = fakeProps();
|
||||
p.sortTypeKey = "nn";
|
||||
const wrapper = shallow(<PathInfoBar {...p} />);
|
||||
wrapper.simulate("click");
|
||||
expect(edit).not.toHaveBeenCalled();
|
||||
expect(error).toHaveBeenCalledWith("Not supported yet.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nearest neighbor algorithm", () => {
|
||||
it("returns optimized array", () => {
|
||||
const p1 = fakePlant();
|
||||
p1.body.x = 100;
|
||||
p1.body.y = 100;
|
||||
const p2 = fakePlant();
|
||||
p2.body.x = 200;
|
||||
p2.body.y = 200;
|
||||
const p3 = fakePlant();
|
||||
p3.body.x = 175;
|
||||
p3.body.y = 1000;
|
||||
const p4 = fakePlant();
|
||||
p4.body.x = 1000;
|
||||
p4.body.y = 150;
|
||||
const points = nn([p4, p2, p3, p1]);
|
||||
expect(points).toEqual([p1, p2, p3, p4]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<NNPath />", () => {
|
||||
const fakeProps = () => ({
|
||||
plants: [],
|
||||
mapTransformProps: fakeMapTransformProps(),
|
||||
});
|
||||
|
||||
it("doesn't render optimized path", () => {
|
||||
const wrapper = mount(<NNPath {...fakeProps()} />);
|
||||
expect(wrapper.html()).toEqual("<g></g>");
|
||||
});
|
||||
|
||||
it("renders optimized path", () => {
|
||||
localStorage.setItem("try_it", "ok");
|
||||
const wrapper = mount(<NNPath {...fakeProps()} />);
|
||||
expect(wrapper.html()).not.toEqual("<g></g>");
|
||||
});
|
||||
});
|
|
@ -13,6 +13,8 @@ import { TaggedPlant } from "../map/interfaces";
|
|||
import { PointGroupSortSelector, sortGroupBy } from "./point_group_sort_selector";
|
||||
import { PointGroupSortType } from "farmbot/dist/resources/api_resources";
|
||||
import { PointGroupItem } from "./point_group_item";
|
||||
import { Paths } from "./paths";
|
||||
import { DevSettings } from "../../account/dev/dev_support";
|
||||
|
||||
interface GroupDetailActiveProps {
|
||||
dispatch: Function;
|
||||
|
@ -97,6 +99,11 @@ export class GroupDetailActive
|
|||
<div className="groups-list-wrapper">
|
||||
{this.icons}
|
||||
</div>
|
||||
{DevSettings.futureFeaturesEnabled() &&
|
||||
<Paths
|
||||
points={this.props.plants}
|
||||
dispatch={this.props.dispatch}
|
||||
group={this.props.group} />}
|
||||
<DeleteButton
|
||||
className="groups-delete-btn"
|
||||
dispatch={this.props.dispatch}
|
||||
|
|
|
@ -6,6 +6,7 @@ import { isUndefined } from "lodash";
|
|||
import { sortGroupBy } from "./point_group_sort_selector";
|
||||
import { Color } from "../../ui";
|
||||
import { transformXY } from "../map/util";
|
||||
import { nn } from "./paths";
|
||||
|
||||
export interface GroupOrderProps {
|
||||
plants: TaggedPlant[];
|
||||
|
@ -14,23 +15,41 @@ export interface GroupOrderProps {
|
|||
|
||||
const sortedPointCoordinates =
|
||||
(plants: TaggedPlant[]): { x: number, y: number }[] => {
|
||||
const group = fetchGroupFromUrl(store.getState().resources.index);
|
||||
const { resources } = store.getState();
|
||||
const group = fetchGroupFromUrl(resources.index);
|
||||
if (isUndefined(group)) { return []; }
|
||||
const groupPlants = plants
|
||||
.filter(p => group.body.point_ids.includes(p.body.id || 0));
|
||||
return sortGroupBy(group.body.sort_type, groupPlants)
|
||||
.map(p => ({ x: p.body.x, y: p.body.y }));
|
||||
const groupSortType = resources.consumers.farm_designer.tryGroupSortType
|
||||
|| group.body.sort_type;
|
||||
const sorted = groupSortType == "nn"
|
||||
? nn(groupPlants)
|
||||
: sortGroupBy(groupSortType, groupPlants);
|
||||
return sorted.map(p => ({ x: p.body.x, y: p.body.y }));
|
||||
};
|
||||
|
||||
export const GroupOrder = (props: GroupOrderProps) => {
|
||||
const points = sortedPointCoordinates(props.plants);
|
||||
return <g id="group-order"
|
||||
stroke={Color.mediumGray} strokeWidth={3} strokeDasharray={12}>
|
||||
{points.map((p, i) => {
|
||||
const prev = i > 0 ? points[i - 1] : p;
|
||||
export interface PointsPathLineProps {
|
||||
orderedPoints: { x: number, y: number }[];
|
||||
mapTransformProps: MapTransformProps;
|
||||
color?: Color;
|
||||
dash?: number;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
export const PointsPathLine = (props: PointsPathLineProps) =>
|
||||
<g id="group-order"
|
||||
stroke={props.color || Color.mediumGray}
|
||||
strokeWidth={props.strokeWidth || 3}
|
||||
strokeDasharray={props.dash || 12}>
|
||||
{props.orderedPoints.map((p, i) => {
|
||||
const prev = i > 0 ? props.orderedPoints[i - 1] : p;
|
||||
const one = transformXY(prev.x, prev.y, props.mapTransformProps);
|
||||
const two = transformXY(p.x, p.y, props.mapTransformProps);
|
||||
return <line key={i} x1={one.qx} y1={one.qy} x2={two.qx} y2={two.qy} />;
|
||||
})}
|
||||
</g>;
|
||||
};
|
||||
|
||||
export const GroupOrder = (props: GroupOrderProps) =>
|
||||
<PointsPathLine
|
||||
orderedPoints={sortedPointCoordinates(props.plants)}
|
||||
mapTransformProps={props.mapTransformProps} />;
|
||||
|
|
|
@ -0,0 +1,129 @@
|
|||
import * as React from "react";
|
||||
import { TaggedPlant, MapTransformProps } from "../map/interfaces";
|
||||
import { sortGroupBy, sortOptionsTable } from "./point_group_sort_selector";
|
||||
import { sortBy } from "lodash";
|
||||
import { PointsPathLine } from "./group_order_visual";
|
||||
import { Color } from "../../ui";
|
||||
import { PointGroupSortType } from "farmbot/dist/resources/api_resources";
|
||||
import { t } from "../../i18next_wrapper";
|
||||
import { Actions } from "../../constants";
|
||||
import { edit } from "../../api/crud";
|
||||
import { TaggedPointGroup } from "farmbot";
|
||||
import { error } from "../../toast/toast";
|
||||
|
||||
const xy = (point: TaggedPlant) => ({ x: point.body.x, y: point.body.y });
|
||||
|
||||
const distance = (p1: { x: number, y: number }, p2: { x: number, y: number }) =>
|
||||
Math.pow(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2), 0.5);
|
||||
|
||||
const pathDistance = (points: TaggedPlant[]) => {
|
||||
let total = 0;
|
||||
let prev: { x: number, y: number } | undefined = undefined;
|
||||
points.map(xy)
|
||||
.map(p => {
|
||||
prev ? total += distance(p, prev) : 0;
|
||||
prev = p;
|
||||
});
|
||||
return Math.round(total);
|
||||
};
|
||||
|
||||
const findNearest =
|
||||
(from: { x: number, y: number }, available: TaggedPlant[]) => {
|
||||
const distances = available.map(p => ({
|
||||
point: p, distance: distance(xy(p), from)
|
||||
}));
|
||||
return sortBy(distances, "distance")[0].point;
|
||||
};
|
||||
|
||||
export const nn = (points: TaggedPlant[]) => {
|
||||
let available = points.slice(0);
|
||||
const ordered: TaggedPlant[] = [];
|
||||
let from = { x: 0, y: 0 };
|
||||
points.map(() => {
|
||||
const nearest = findNearest(from, available);
|
||||
ordered.push(nearest);
|
||||
from = { x: nearest.body.x, y: nearest.body.y };
|
||||
available = available.filter(p => p.uuid !== nearest.uuid);
|
||||
});
|
||||
return ordered;
|
||||
};
|
||||
|
||||
const SORT_TYPES: (PointGroupSortType | "nn")[] = [
|
||||
"random", "xy_ascending", "xy_descending", "yx_ascending", "yx_descending"];
|
||||
|
||||
export interface PathInfoBarProps {
|
||||
sortTypeKey: PointGroupSortType | "nn";
|
||||
dispatch: Function;
|
||||
group: TaggedPointGroup;
|
||||
pathData: { [key: string]: number };
|
||||
}
|
||||
|
||||
export const PathInfoBar = (props: PathInfoBarProps) => {
|
||||
const { sortTypeKey, dispatch, group } = props;
|
||||
const pathLength = props.pathData[sortTypeKey];
|
||||
const maxLength = Math.max(...Object.values(props.pathData));
|
||||
const normalizedLength = pathLength / maxLength * 100;
|
||||
const sortLabel =
|
||||
sortTypeKey == "nn" ? "Optimized" : sortOptionsTable()[sortTypeKey];
|
||||
return <div className={"sort-path-info-bar"}
|
||||
onMouseEnter={() =>
|
||||
dispatch({ type: Actions.TRY_SORT_TYPE, payload: sortTypeKey })}
|
||||
onMouseLeave={() =>
|
||||
dispatch({ type: Actions.TRY_SORT_TYPE, payload: undefined })}
|
||||
onClick={() =>
|
||||
sortTypeKey == "nn"
|
||||
? error(t("Not supported yet."))
|
||||
: dispatch(edit(group, { sort_type: sortTypeKey }))}
|
||||
style={{ width: `${normalizedLength}%` }}>
|
||||
{`${sortLabel}: ${Math.round(pathLength / 10) / 100}m`}
|
||||
</div>;
|
||||
};
|
||||
|
||||
interface PathsProps {
|
||||
points: TaggedPlant[];
|
||||
dispatch: Function;
|
||||
group: TaggedPointGroup;
|
||||
}
|
||||
|
||||
interface PathsState {
|
||||
pathData: { [key: string]: number };
|
||||
}
|
||||
|
||||
export class Paths extends React.Component<PathsProps, PathsState> {
|
||||
state: PathsState = { pathData: {} };
|
||||
|
||||
generatePathData = (points: TaggedPlant[]) => {
|
||||
SORT_TYPES.map((sortType: PointGroupSortType) =>
|
||||
this.state.pathData[sortType] =
|
||||
pathDistance(sortGroupBy(sortType, points)));
|
||||
this.state.pathData.nn = pathDistance(nn(points));
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.state.pathData.nn) { this.generatePathData(this.props.points); }
|
||||
return <div>
|
||||
<label>{t("Path lengths by sort type")}</label>
|
||||
{SORT_TYPES.concat("nn").map(st =>
|
||||
<PathInfoBar key={st}
|
||||
sortTypeKey={st}
|
||||
dispatch={this.props.dispatch}
|
||||
group={this.props.group}
|
||||
pathData={this.state.pathData} />)}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
interface NNPathProps {
|
||||
plants: TaggedPlant[];
|
||||
mapTransformProps: MapTransformProps;
|
||||
}
|
||||
|
||||
export const NNPath = (props: NNPathProps) =>
|
||||
localStorage.getItem("try_it") == "ok"
|
||||
? <PointsPathLine
|
||||
color={Color.blue}
|
||||
strokeWidth={2}
|
||||
dash={1}
|
||||
orderedPoints={nn(props.plants).map(xy)}
|
||||
mapTransformProps={props.mapTransformProps} />
|
||||
: <g />;
|
|
@ -11,7 +11,7 @@ interface Props {
|
|||
value: PointGroupSortType;
|
||||
}
|
||||
|
||||
const optionsTable = (): Record<PointGroupSortType, string> => ({
|
||||
export const sortOptionsTable = (): Record<PointGroupSortType, string> => ({
|
||||
"random": t("Random Order"),
|
||||
"xy_ascending": t("X/Y, Ascending"),
|
||||
"xy_descending": t("X/Y, Descending"),
|
||||
|
@ -21,7 +21,7 @@ const optionsTable = (): Record<PointGroupSortType, string> => ({
|
|||
|
||||
const optionPlusDescriptions = () =>
|
||||
(Object
|
||||
.entries(optionsTable()) as [PointGroupSortType, string][])
|
||||
.entries(sortOptionsTable()) as [PointGroupSortType, string][])
|
||||
.map(x => ({ label: x[1], value: x[0] }));
|
||||
|
||||
const optionList =
|
||||
|
@ -32,7 +32,7 @@ export const isSortType = (x: unknown): x is PointGroupSortType => {
|
|||
};
|
||||
|
||||
const selected = (value: PointGroupSortType) => ({
|
||||
label: t(optionsTable()[value] || value),
|
||||
label: t(sortOptionsTable()[value] || value),
|
||||
value: value
|
||||
});
|
||||
|
||||
|
@ -50,6 +50,7 @@ export function PointGroupSortSelector(p: Props) {
|
|||
</label>
|
||||
</div>
|
||||
<FBSelect
|
||||
key={p.value}
|
||||
list={optionPlusDescriptions()}
|
||||
selectedItem={selected(p.value as PointGroupSortType)}
|
||||
onChange={sortTypeChange(p.onChange)} />
|
||||
|
|
|
@ -5,6 +5,7 @@ import { cloneDeep } from "lodash";
|
|||
import { TaggedResource } from "farmbot";
|
||||
import { Actions } from "../constants";
|
||||
import { BotPosition } from "../devices/interfaces";
|
||||
import { PointGroupSortType } from "farmbot/dist/resources/api_resources";
|
||||
|
||||
export let initialState: DesignerState = {
|
||||
selectedPlants: undefined,
|
||||
|
@ -19,6 +20,7 @@ export let initialState: DesignerState = {
|
|||
chosenLocation: { x: undefined, y: undefined, z: undefined },
|
||||
currentPoint: undefined,
|
||||
openedSavedGarden: undefined,
|
||||
tryGroupSortType: undefined,
|
||||
};
|
||||
|
||||
export let designer = generateReducer<DesignerState>(initialState)
|
||||
|
@ -69,4 +71,8 @@ export let designer = generateReducer<DesignerState>(initialState)
|
|||
.add<string | undefined>(Actions.CHOOSE_SAVED_GARDEN, (s, { payload }) => {
|
||||
s.openedSavedGarden = payload;
|
||||
return s;
|
||||
})
|
||||
.add<PointGroupSortType | undefined>(Actions.TRY_SORT_TYPE, (s, { payload }) => {
|
||||
s.tryGroupSortType = payload;
|
||||
return s;
|
||||
});
|
||||
|
|
|
@ -9,7 +9,7 @@ export function findBySlug(
|
|||
return crop || {
|
||||
crop: {
|
||||
name: startCase((slug || t("Name")).split("-").join(" ")),
|
||||
slug: "slug",
|
||||
slug: slug || "slug",
|
||||
binomial_name: t("Binomial Name"),
|
||||
common_names: [t("Common Names")],
|
||||
description: t("Description"),
|
||||
|
|
Loading…
Reference in New Issue