Skip to content

Commit ffb0aca

Browse files
authored
Implement routed drawing (placemark#236)
1 parent 45b95e7 commit ffb0aca

13 files changed

Lines changed: 461 additions & 4 deletions

File tree

.changeset/lemon-parts-play.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@placemarkio/play": minor
3+
---
4+
5+
Routing as a new drawing mode
6+
7+
This lets you draw waypoints on the map and Placemark will use
8+
a routing API to connected the points with roads and paths.

app/components/dialogs.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { Dialog as D } from "radix-ui";
1818
import { memo, Suspense, useCallback } from "react";
1919
import { dialogAtom } from "state/jotai";
2020
import { match } from "ts-pattern";
21+
import { RouteHelpDialog } from "./dialogs/route_help";
2122
import {
2223
type B3Size,
2324
DefaultErrorBoundary,
@@ -57,6 +58,7 @@ export const Dialogs = memo(function Dialogs() {
5758
))
5859
.with({ type: "cheatsheet" }, () => <CheatsheetDialog />)
5960
.with({ type: "circle_types" }, () => <CircleTypesDialog />)
61+
.with({ type: "route_help" }, () => <RouteHelpDialog />)
6062
.with({ type: "circle" }, (modal) => (
6163
<CircleDialog modal={modal} onClose={onClose} />
6264
))
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { CommitIcon } from "@radix-ui/react-icons";
2+
import { DialogHeader } from "app/components/dialog";
3+
import { styledInlineA } from "../elements";
4+
5+
export function RouteHelpDialog() {
6+
return (
7+
<>
8+
<DialogHeader title="Route help" titleIcon={CommitIcon} />
9+
<div>
10+
<p>
11+
Routing is a beta feature! Please leave feedback and consider{" "}
12+
<a
13+
href="https://github.com/placemark/placemark"
14+
className={styledInlineA}
15+
>
16+
helping out with development
17+
</a>{" "}
18+
of this.
19+
</p>
20+
<p className="mt-2">
21+
Draw a route by clicking <CommitIcon className="w-4 inline-block" />{" "}
22+
and clicking on the map to add a waypoint. Using the menu to the right
23+
of the block, you can customize whether the route using walking,
24+
driving, or cycling profiles.
25+
</p>
26+
<p className="mt-2">
27+
Routes are represented as GeometryCollection objects with points for
28+
waypoints and a linestring for the route. They're currently generated
29+
by the{" "}
30+
<a
31+
href="https://docs.mapbox.com/api/navigation/directions/"
32+
className={styledInlineA}
33+
>
34+
Mapbox Directions API
35+
</a>
36+
. Additional routing providers would be welcomed - please contribute
37+
to the open source project if this is a priority for you.
38+
</p>
39+
<p>
40+
<h2 className="mt-8 font-bold">Known limitations</h2>
41+
<ul className="list-disc ml-6 mt-2">
42+
<li>
43+
You can't add control points to the middle of routes, or extend
44+
routes once they've been drawn.
45+
</li>
46+
<li>
47+
Other than doing 'Split GeometryCollection', there is not a very
48+
intuitive way to turn a route into a normal LineString.
49+
</li>
50+
</ul>
51+
</p>
52+
</div>
53+
</>
54+
);
55+
}

app/components/mode_hints.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,21 @@ export function ModeHints() {
9393
</ModeHint>
9494
);
9595
}
96+
case Mode.DRAW_ROUTE: {
97+
return (
98+
<ModeHint mode={mode.mode}>
99+
{selection.type === "single" ? (
100+
<div>
101+
Finish by hitting escape
102+
<br />
103+
{hold(true)}
104+
</div>
105+
) : (
106+
"Click to start the route, then click to add each waypoint"
107+
)}
108+
</ModeHint>
109+
);
110+
}
96111
case Mode.NONE: {
97112
if (selection.type === "single") {
98113
if (mode.modeOptions?.hasResizedRectangle) {

app/components/modes.tsx

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
CaretDownIcon,
33
CheckIcon,
4+
CommitIcon,
45
CursorArrowIcon,
56
DotFilledIcon,
67
PlusIcon,
@@ -31,10 +32,53 @@ import {
3132
MODE_INFO,
3233
Mode,
3334
modeAtom,
35+
routeTypeAtom,
3436
} from "state/jotai";
35-
import { CIRCLE_TYPE } from "state/mode";
37+
import { CIRCLE_TYPE, ROUTE_TYPE } from "state/mode";
3638
import type { IWrappedFeature } from "types";
3739

40+
function RouteMenu() {
41+
const [routeType, setRouteType] = useAtom(routeTypeAtom);
42+
const setDialogState = useSetAtom(dialogAtom);
43+
44+
return (
45+
<div className="z-50">
46+
<DD.Root>
47+
<DD.Trigger asChild>
48+
<Button size="xxs" variant="quiet">
49+
<CaretDownIcon />
50+
</Button>
51+
</DD.Trigger>
52+
<DDContent>
53+
<DDLabel>Route type</DDLabel>
54+
{[ROUTE_TYPE.DRIVING, ROUTE_TYPE.WALKING, ROUTE_TYPE.CYCLING].map(
55+
(type) => (
56+
<StyledItem
57+
key={type}
58+
onSelect={() => {
59+
setRouteType(type);
60+
}}
61+
>
62+
<CheckIcon className={routeType === type ? "" : "opacity-0"} />
63+
{type}
64+
</StyledItem>
65+
),
66+
)}
67+
<DDSeparator />
68+
<StyledItem
69+
onSelect={() => {
70+
setDialogState({ type: "route_help" });
71+
}}
72+
>
73+
<QuestionMarkCircledIcon className="h-3 w-3" />{" "}
74+
<span className="text-xs">Help</span>
75+
</StyledItem>
76+
</DDContent>
77+
</DD.Root>
78+
</div>
79+
);
80+
}
81+
3882
function CircleMenu() {
3983
const [circleType, setCircleType] = useAtom(circleTypeAtom);
4084
const setData = useSetAtom(dataAtom);
@@ -134,6 +178,12 @@ const MODE_OPTIONS = [
134178
Icon: CircleIcon,
135179
Menu: CircleMenu,
136180
},
181+
{
182+
mode: Mode.DRAW_ROUTE,
183+
hotkey: "7",
184+
Icon: CommitIcon,
185+
Menu: RouteMenu,
186+
},
137187
] as const;
138188

139189
export default memo(function Modes({

app/lib/handlers/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useNoneHandlers } from "app/lib/handlers/none";
55
import { usePointHandlers } from "app/lib/handlers/point";
66
import { usePolygonHandlers } from "app/lib/handlers/polygon";
77
import { useRectangleHandlers } from "app/lib/handlers/rectangle";
8+
import { useRouteHandlers } from "app/lib/handlers/route";
89
import { Mode } from "state/jotai";
910
import type { HandlerContext } from "types";
1011

@@ -13,6 +14,7 @@ export function useHandlers(handlerContext: HandlerContext) {
1314
[Mode.NONE]: useNoneHandlers(handlerContext),
1415
[Mode.DRAW_POINT]: usePointHandlers(handlerContext),
1516
[Mode.DRAW_LINE]: useLineHandlers(handlerContext),
17+
[Mode.DRAW_ROUTE]: useRouteHandlers(handlerContext),
1618
[Mode.DRAW_POLYGON]: usePolygonHandlers(handlerContext),
1719
[Mode.DRAW_RECTANGLE]: useRectangleHandlers(handlerContext),
1820
[Mode.DRAW_CIRCLE]: useCircleHandlers(handlerContext),

app/lib/handlers/none.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ import {
2222
Mode,
2323
selectionAtom,
2424
} from "state/jotai";
25-
import { modeAtom } from "state/mode";
25+
import { modeAtom, type ROUTE_TYPE } from "state/mode";
2626
import type { HandlerContext } from "types";
27-
import { getMapCoord, getSnappingCoordinates } from "./utils";
27+
import { getMapCoord, getSnappingCoordinates, transactRoute } from "./utils";
2828

2929
export function useNoneHandlers({
3030
setFlatbushInstance,
@@ -208,6 +208,22 @@ export function useNoneHandlers({
208208
dragTargetRef.current = null;
209209
void endSnapshot();
210210
setCursor(CURSOR_DEFAULT);
211+
if (selection.type === "single") {
212+
const newFeature = featureMap.get(selection.id);
213+
214+
if (newFeature) {
215+
const typeProperty = newFeature.feature.properties?.["@type"];
216+
217+
if (
218+
typeProperty === "route:walking" ||
219+
typeProperty === "route:cycling" ||
220+
typeProperty === "route:driving"
221+
) {
222+
const routeType = typeProperty.split(":")[1] as ROUTE_TYPE;
223+
return transactRoute(transact, newFeature, routeType);
224+
}
225+
}
226+
}
211227
},
212228
move: (e) => {
213229
if (dragTargetRef.current === null) {

0 commit comments

Comments
 (0)