add sort comparison UI

pull/1551/head
gabrielburnworth 2019-10-30 11:25:29 -07:00
parent 7d3a525847
commit 71bb473a1a
14 changed files with 324 additions and 15 deletions

View File

@ -13,4 +13,5 @@ export const fakeDesignerState = (): DesignerState => ({
chosenLocation: { x: undefined, y: undefined, z: undefined },
currentPoint: undefined,
openedSavedGarden: undefined,
tryGroupSortType: undefined,
});

View File

@ -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",

View File

@ -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%);
}
}

View File

@ -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");
});
});

View File

@ -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;

View File

@ -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>

View File

@ -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");
});
});

View File

@ -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>");
});
});

View File

@ -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}

View File

@ -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} />;

View File

@ -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 />;

View File

@ -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)} />

View File

@ -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;
});

View File

@ -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"),