diff --git a/assets/src/components/detours/diversionPage.tsx b/assets/src/components/detours/diversionPage.tsx
index 6e07527d5..11227d5c8 100644
--- a/assets/src/components/detours/diversionPage.tsx
+++ b/assets/src/components/detours/diversionPage.tsx
@@ -3,9 +3,9 @@ import { DiversionPanel, DiversionPanelProps } from "./diversionPanel"
import { DetourMap } from "./detourMap"
import { Shape } from "../../schedule"
import { useDetour } from "../../hooks/useDetour"
+import { ListGroup } from "react-bootstrap"
export const DiversionPage = ({
- directions,
missedStops,
routeName,
routeDescription,
@@ -22,6 +22,7 @@ export const DiversionPage = ({
waypoints,
detourShape,
+ directions,
canUndo,
undoLastWaypoint,
@@ -34,7 +35,15 @@ export const DiversionPage = ({
+ {directions?.map((d) => (
+
+ {d.instruction}
+
+ ))}
+
+ }
missedStops={missedStops}
routeName={routeName}
routeDescription={routeDescription}
diff --git a/assets/src/components/dummyDetourPage.tsx b/assets/src/components/dummyDetourPage.tsx
index c8b3c014b..4b5f7faf8 100644
--- a/assets/src/components/dummyDetourPage.tsx
+++ b/assets/src/components/dummyDetourPage.tsx
@@ -1,28 +1,34 @@
import React, { useEffect, useState } from "react"
-import { fetchShapeForRoute } from "../api"
-import { Shape } from "../schedule"
+import { fetchRoutePatterns } from "../api"
+import { RoutePattern } from "../schedule"
import { DiversionPage } from "./detours/diversionPage"
+import { useRoute } from "../contexts/routesContext"
export const DummyDetourPage = () => {
- const [routeShape, setRouteShape] = useState(null)
+ const [routePattern, setRoutePattern] = useState(null)
+
+ const routeNumber = "66"
useEffect(() => {
- fetchShapeForRoute("39").then((shapes) => {
- setRouteShape(shapes[0])
+ fetchRoutePatterns(routeNumber).then((routePatterns) => {
+ setRoutePattern(routePatterns[0])
})
}, [])
+ const route = useRoute(routePattern?.routeId)
return (
-
- {routeShape && (
+ <>
+ {routePattern && routePattern.shape && (
)}
-
+ >
)
}
diff --git a/assets/src/detour.d.ts b/assets/src/detour.ts
similarity index 68%
rename from assets/src/detour.d.ts
rename to assets/src/detour.ts
index 9f58a847f..e4629a399 100644
--- a/assets/src/detour.d.ts
+++ b/assets/src/detour.ts
@@ -2,4 +2,7 @@ import { ShapePoint } from "./schedule"
export interface DetourShape {
coordinates: ShapePoint[]
+ directions: {
+ instruction: string
+ }[]
}
diff --git a/assets/src/hooks/useDetour.tsx b/assets/src/hooks/useDetour.tsx
index 74c9ae61a..d48b04760 100644
--- a/assets/src/hooks/useDetour.tsx
+++ b/assets/src/hooks/useDetour.tsx
@@ -1,9 +1,13 @@
import { useEffect, useMemo, useState } from "react"
import { ShapePoint } from "../schedule"
import { fetchDetourDirections } from "../api"
+import { DetourShape } from "../detour"
const useDetourDirections = (shapePoints: ShapePoint[]) => {
const [detourShape, setDetourShape] = useState([])
+ const [directions, setDirections] = useState<
+ DetourShape["directions"] | undefined
+ >(undefined)
useEffect(() => {
let shouldUpdate = true
@@ -12,9 +16,10 @@ const useDetourDirections = (shapePoints: ShapePoint[]) => {
return
}
- fetchDetourDirections(shapePoints).then((detourShape) => {
- if (detourShape && shouldUpdate) {
- setDetourShape(detourShape.coordinates)
+ fetchDetourDirections(shapePoints).then((detourInfo) => {
+ if (detourInfo && shouldUpdate) {
+ setDetourShape(detourInfo.coordinates)
+ setDirections(detourInfo.directions)
}
})
@@ -23,7 +28,10 @@ const useDetourDirections = (shapePoints: ShapePoint[]) => {
}
}, [shapePoints])
- return detourShape
+ return {
+ detourShape,
+ directions,
+ }
}
export const useDetour = () => {
@@ -31,14 +39,14 @@ export const useDetour = () => {
const [endPoint, setEndPoint] = useState(null)
const [waypoints, setWaypoints] = useState([])
- const detourShape = useDetourDirections(
+ const { detourShape, directions } = useDetourDirections(
useMemo(
() =>
[startPoint, ...waypoints, endPoint].filter(
(v): v is ShapePoint => !!v
),
[startPoint, waypoints, endPoint]
- )
+ ) ?? []
)
const canAddWaypoint = () => startPoint !== null && endPoint === null
@@ -92,6 +100,10 @@ export const useDetour = () => {
* The routing API generated detour shape.
*/
detourShape,
+ /**
+ * The turn-by-turn directions generated by the routing API.
+ */
+ directions,
/**
* Reports if {@link undoLastWaypoint} will do anything.
diff --git a/assets/src/models/detourShapeData.ts b/assets/src/models/detourShapeData.ts
index 748001055..d0593df4a 100644
--- a/assets/src/models/detourShapeData.ts
+++ b/assets/src/models/detourShapeData.ts
@@ -1,4 +1,4 @@
-import { array, Infer, number, type } from "superstruct"
+import { array, Infer, number, string, type } from "superstruct"
import { DetourShape } from "../detour"
export const DetourShapeData = type({
@@ -8,6 +8,11 @@ export const DetourShapeData = type({
lon: number(),
})
),
+ directions: array(
+ type({
+ instruction: string(),
+ })
+ ),
})
export type DetourShapeData = Infer
diff --git a/assets/tests/factories/detourShapeFactory.ts b/assets/tests/factories/detourShapeFactory.ts
new file mode 100644
index 000000000..128d9d3d2
--- /dev/null
+++ b/assets/tests/factories/detourShapeFactory.ts
@@ -0,0 +1,16 @@
+import { Factory } from "fishery"
+import { DetourShape } from "../../src/detour"
+import { shapePointFactory } from "./shapePointFactory"
+
+export const directionsFactory = Factory.define(
+ ({ sequence }) => {
+ return {
+ instruction: `directionInstruction${sequence}`,
+ }
+ }
+)
+
+export const detourShapeFactory = Factory.define(() => ({
+ coordinates: shapePointFactory.buildList(3),
+ directions: directionsFactory.buildList(3),
+}))
diff --git a/assets/tests/factories/shapePointFactory.ts b/assets/tests/factories/shapePointFactory.ts
new file mode 100644
index 000000000..31244520f
--- /dev/null
+++ b/assets/tests/factories/shapePointFactory.ts
@@ -0,0 +1,12 @@
+import { Factory } from "fishery"
+import { localGeoCoordinateFactory } from "./geoCoordinate"
+import { ShapePoint } from "../../src/schedule"
+
+/**
+ * Wrapper around {@link localGeoCoordinateFactory} until {@link ShapePoint} is
+ * updated to a global shared coordinate object
+ */
+export const shapePointFactory = Factory.define(() => {
+ const { latitude, longitude } = localGeoCoordinateFactory.build()
+ return { lat: latitude, lon: longitude }
+})
diff --git a/assets/tests/hooks/useDetour.test.ts b/assets/tests/hooks/useDetour.test.ts
index 0fdf91d94..b4d2331bf 100644
--- a/assets/tests/hooks/useDetour.test.ts
+++ b/assets/tests/hooks/useDetour.test.ts
@@ -3,6 +3,8 @@ import { fetchDetourDirections } from "../../src/api"
import { renderHook, waitFor } from "@testing-library/react"
import { useDetour } from "../../src/hooks/useDetour"
import { act } from "react-dom/test-utils"
+import { detourShapeFactory } from "../factories/detourShapeFactory"
+import { ShapePoint } from "../../src/schedule"
jest.mock("../../src/api")
@@ -41,36 +43,41 @@ describe("useDetour", () => {
act(() => result.current.addWaypoint({ lat: 0, lon: 0 }))
- expect(result.current.waypoints).toEqual([])
+ expect(result.current.waypoints).toHaveLength(0)
})
test("when `endPoint` is set, `addWaypoint` does nothing", () => {
- const start = { lat: 0, lon: 0 }
- const end = { lat: 1, lon: 1 }
-
const { result } = renderHook(useDetour)
expect(result.current.startPoint).toBeNull()
- act(() => result.current.addConnectionPoint(start))
- act(() => result.current.addConnectionPoint(end))
+ act(() => result.current.addConnectionPoint({ lat: 0, lon: 0 }))
+ act(() => result.current.addConnectionPoint({ lat: 1, lon: 1 }))
act(() => result.current.addWaypoint({ lat: 0, lon: 0 }))
- expect(result.current.waypoints).toEqual([])
+ expect(result.current.waypoints).toHaveLength(0)
})
- test("when `addWaypoint` is called, `detourShape` is updated", async () => {
- const start = { lat: 0, lon: 0 }
- const end = { lat: 1, lon: 1 }
- const apiResult = [
- { lat: -1, lon: -1 },
- { lat: -2, lon: -2 },
- ]
+ test("when `addWaypoint` is called, should update `detourShape` and `directions`", async () => {
+ const start: ShapePoint = { lat: -2, lon: -2 }
+ const end: ShapePoint = { lat: -1, lon: -1 }
+
+ const detourShape = detourShapeFactory.build({
+ coordinates: [
+ { lat: 0, lon: 0 },
+ { lat: 1, lon: 1 },
+ { lat: 2, lon: 2 },
+ ],
+ directions: [
+ { instruction: "Turn Left onto Main St" },
+ { instruction: "Turn Right onto High St" },
+ ],
+ })
jest.mocked(fetchDetourDirections).mockImplementation((coordinates) => {
expect(coordinates).toStrictEqual([start, end])
- return Promise.resolve({ coordinates: apiResult })
+ return Promise.resolve(detourShape)
})
const { result } = renderHook(useDetour)
@@ -80,14 +87,16 @@ describe("useDetour", () => {
expect(result.current.startPoint).toBe(start)
+ expect(jest.mocked(fetchDetourDirections)).toHaveBeenCalledTimes(1)
expect(jest.mocked(fetchDetourDirections)).toHaveBeenNthCalledWith(1, [
start,
end,
])
- await waitFor(() =>
- expect(result.current.detourShape).toStrictEqual(apiResult)
- )
+ await waitFor(() => {
+ expect(result.current.detourShape).toStrictEqual(detourShape.coordinates)
+ expect(result.current.directions).toStrictEqual(detourShape.directions)
+ })
})
test("when `undoLastWaypoint` is called, removes the last `waypoint`", async () => {
@@ -103,7 +112,7 @@ describe("useDetour", () => {
act(() => result.current.undoLastWaypoint())
- expect(result.current.waypoints).toStrictEqual([])
+ expect(result.current.waypoints).toHaveLength(0)
})
test("when `undoLastWaypoint` is called, should call API with updated waypoints", async () => {
@@ -130,7 +139,7 @@ describe("useDetour", () => {
act(() => result.current.addConnectionPoint({ lat: 0, lon: 0 }))
- expect(result.current.waypoints).toStrictEqual([])
+ expect(result.current.waypoints).toHaveLength(0)
expect(result.current.canUndo).toBe(false)
})
@@ -140,6 +149,7 @@ describe("useDetour", () => {
act(() => result.current.addConnectionPoint({ lat: 0, lon: 0 }))
act(() => result.current.addWaypoint({ lat: 1, lon: 1 }))
+ expect(result.current.waypoints).not.toHaveLength(0)
expect(result.current.canUndo).toBe(true)
})
@@ -149,6 +159,7 @@ describe("useDetour", () => {
act(() => result.current.addConnectionPoint({ lat: 0, lon: 0 }))
act(() => result.current.addConnectionPoint({ lat: 0, lon: 0 }))
+ expect(result.current.endPoint).not.toBeNull()
expect(result.current.canUndo).toBe(false)
})
})
diff --git a/lib/skate/open_route_service_api.ex b/lib/skate/open_route_service_api.ex
index 5290fa919..0b16f92a3 100644
--- a/lib/skate/open_route_service_api.ex
+++ b/lib/skate/open_route_service_api.ex
@@ -21,6 +21,14 @@ defmodule Skate.OpenRouteServiceAPI do
%{"lat" => 0, "lon" => 0},
%{"lat" => 0.5, "lon" => 0.1},
%{"lat" => 1, "lon" => 0}
+ ],
+ directions: [
+ %{
+ instruction: "Turn right onto 1st Avenue"
+ },
+ %{
+ instruction: "Turn left onto 2nd Place"
+ }
]
}
}
@@ -30,10 +38,10 @@ defmodule Skate.OpenRouteServiceAPI do
## Examples
iex> Skate.OpenRouteServiceAPI.directions([])
- {:ok, %Skate.OpenRouteServiceAPI.DirectionsResponse{coordinates: []}}
+ {:ok, %Skate.OpenRouteServiceAPI.DirectionsResponse{coordinates: [], directions: []}}
iex> Skate.OpenRouteServiceAPI.directions([%{"lat" => 0, "lon" => 0}])
- {:ok, %Skate.OpenRouteServiceAPI.DirectionsResponse{coordinates: []}}
+ {:ok, %Skate.OpenRouteServiceAPI.DirectionsResponse{coordinates: [], directions: []}}
If anything goes wrong, then this returns an error instead.
@@ -63,13 +71,49 @@ defmodule Skate.OpenRouteServiceAPI do
end
defp parse_directions(payload) do
- %{"features" => [%{"geometry" => %{"coordinates" => coordinates}}]} = payload
+ %{
+ "features" => [
+ %{
+ "geometry" => %{"coordinates" => coordinates},
+ "properties" => %{"segments" => segments}
+ }
+ ]
+ } = payload
{:ok,
%DirectionsResponse{
- coordinates: Enum.map(coordinates, fn [lon, lat] -> %{"lat" => lat, "lon" => lon} end)
+ coordinates: Enum.map(coordinates, fn [lon, lat] -> %{"lat" => lat, "lon" => lon} end),
+ directions:
+ segments
+ |> Enum.flat_map(& &1["steps"])
+ |> Enum.filter(fn %{"type" => type} -> map_type(type) not in [:goal, :error] end)
+ |> Enum.map(
+ &%{
+ instruction: &1["instruction"]
+ }
+ )
}}
end
defp client(), do: Application.get_env(:skate, Skate.OpenRouteServiceAPI)[:client]
+
+ defp map_type(type_id) do
+ case type_id do
+ 0 -> :left
+ 1 -> :right
+ 2 -> :sharp_left
+ 3 -> :sharp_right
+ 4 -> :slight_left
+ 5 -> :slight_right
+ 6 -> :straight
+ 7 -> :enter_roundabout
+ 8 -> :exit_roundabout
+ 9 -> :u_turn
+ 10 -> :goal
+ 11 -> :depart
+ 12 -> :keep_left
+ 13 -> :keep_right
+ _ -> :error
+ end
+ end
end
diff --git a/lib/skate/open_route_service_api/directions_response.ex b/lib/skate/open_route_service_api/directions_response.ex
index eec5bfcc2..d7c3a410c 100644
--- a/lib/skate/open_route_service_api/directions_response.ex
+++ b/lib/skate/open_route_service_api/directions_response.ex
@@ -8,8 +8,31 @@ defmodule Skate.OpenRouteServiceAPI.DirectionsResponse do
Type that represents a request made to OpenRouteService's Directions API
"""
@type t() :: %__MODULE__{
- coordinates: [[float()]]
+ coordinates: [[float()]],
+ directions: [
+ %{
+ instruction: binary(),
+ name: binary(),
+ type: direction_t()
+ }
+ ]
}
- defstruct coordinates: []
+ @type direction_t() ::
+ :left
+ | :right
+ | :sharp_left
+ | :sharp_right
+ | :slight_left
+ | :slight_right
+ | :straight
+ | :enter_roundabout
+ | :exit_roundabout
+ | :u_turn
+ | :goal
+ | :depart
+ | :keep_left
+ | :keep_right
+
+ defstruct coordinates: [], directions: []
end
diff --git a/test/skate/open_route_service_api_test.exs b/test/skate/open_route_service_api_test.exs
index 2a8ec2da9..62714f583 100644
--- a/test/skate/open_route_service_api_test.exs
+++ b/test/skate/open_route_service_api_test.exs
@@ -25,6 +25,33 @@ defmodule Skate.OpenRouteServiceAPITest do
[0.1, 0.5],
[0, 1]
]
+ },
+ "properties" => %{
+ "segments" => [
+ %{
+ "steps" => [
+ %{
+ "instruction" => "Turn right onto 1st Avenue",
+ "name" => "1st Avenue",
+ "type" => 1
+ }
+ ]
+ },
+ %{
+ "steps" => [
+ %{
+ "instruction" => "Turn left onto 2nd Place",
+ "name" => "2nd Place",
+ "type" => 0
+ },
+ %{
+ "instruction" => "Arrive",
+ "name" => "-",
+ "type" => 10
+ }
+ ]
+ }
+ ]
}
}
]
diff --git a/test/skate_web/controllers/detour_route_controller_test.exs b/test/skate_web/controllers/detour_route_controller_test.exs
index cabf1fa22..4f7b29cb5 100644
--- a/test/skate_web/controllers/detour_route_controller_test.exs
+++ b/test/skate_web/controllers/detour_route_controller_test.exs
@@ -3,6 +3,7 @@ defmodule SkateWeb.DetourRouteControllerTest do
use SkateWeb.ConnCase
import Mox
+ import Skate.Factory
setup :verify_on_exit!
@@ -11,14 +12,10 @@ defmodule SkateWeb.DetourRouteControllerTest do
end
describe "directions" do
- defp directions_json(coordinates: coordinates) do
- %{"features" => [%{"geometry" => %{"coordinates" => coordinates}}]}
- end
-
@tag :authenticated
test "returns shape data as geojson", %{conn: conn} do
expect(Skate.OpenRouteServiceAPI.MockClient, :get_directions, fn _ ->
- {:ok, directions_json(coordinates: [[0, 0], [0.5, 0.5], [1, 1]])}
+ {:ok, build(:ors_directions_json, coordinates: [[0, 0], [0.5, 0.5], [1, 1]])}
end)
conn =
@@ -38,12 +35,59 @@ defmodule SkateWeb.DetourRouteControllerTest do
json_response(conn, 200)
end
+ @tag :authenticated
+ test "returns directions as a flat list", %{conn: conn} do
+ expect(Skate.OpenRouteServiceAPI.MockClient, :get_directions, fn _ ->
+ {:ok,
+ build(:ors_directions_json,
+ segments: [
+ %{
+ "steps" => [
+ %{
+ "instruction" => "1",
+ "type" => 1
+ },
+ %{
+ "instruction" => "2",
+ "type" => 0
+ }
+ ]
+ },
+ %{
+ "steps" => [
+ %{
+ "instruction" => "3",
+ "type" => 2
+ }
+ ]
+ }
+ ]
+ )}
+ end)
+
+ conn =
+ post(conn, ~p"/api/detours/directions",
+ coordinates: [%{"lat" => 0, "lon" => 0}, %{"lat" => 1, "lon" => 1}]
+ )
+
+ assert %{
+ "data" => %{
+ "directions" => [
+ %{"instruction" => "1"},
+ %{"instruction" => "2"},
+ %{"instruction" => "3"}
+ ]
+ }
+ } =
+ json_response(conn, 200)
+ end
+
@tag :authenticated
test "formats input coordinates as [lon, lat]", %{conn: conn} do
expect(Skate.OpenRouteServiceAPI.MockClient, :get_directions, fn request ->
assert %DirectionsRequest{coordinates: [[100, 1], [101, 2]]} = request
- {:ok, directions_json(coordinates: [[0, 0], [0.5, 0.5], [1, 1]])}
+ {:ok, build(:ors_directions_json, coordinates: [[0, 0], [0.5, 0.5], [1, 1]])}
end)
post(conn, ~p"/api/detours/directions",
@@ -54,7 +98,7 @@ defmodule SkateWeb.DetourRouteControllerTest do
@tag :authenticated
test "interprets output coordinates as [lon, lat]", %{conn: conn} do
expect(Skate.OpenRouteServiceAPI.MockClient, :get_directions, fn _ ->
- {:ok, directions_json(coordinates: [[100, 0], [101, 1]])}
+ {:ok, build(:ors_directions_json, coordinates: [[100, 0], [101, 1]])}
end)
conn =
@@ -80,7 +124,8 @@ defmodule SkateWeb.DetourRouteControllerTest do
assert %{
"data" => %{
- "coordinates" => []
+ "coordinates" => [],
+ "directions" => []
}
} =
json_response(conn, 200)
@@ -93,7 +138,8 @@ defmodule SkateWeb.DetourRouteControllerTest do
assert %{
"data" => %{
- "coordinates" => []
+ "coordinates" => [],
+ "directions" => []
}
} =
json_response(conn, 200)
diff --git a/test/support/factory.ex b/test/support/factory.ex
index 46cd3ee64..04a1b09fb 100644
--- a/test/support/factory.ex
+++ b/test/support/factory.ex
@@ -297,4 +297,42 @@ defmodule Skate.Factory do
username: sequence("test_user")
}
end
+
+ def ors_directions_step_json_factory do
+ %{
+ "instruction" => sequence("ors_instruction_step"),
+ "name" => sequence("ors_instruction_name"),
+ "type" => sequence("ors_instruction_type", [0, 1, 2, 3, 4, 5, 6, 7, 12, 13])
+ }
+ end
+
+ def ors_directions_segment_json_factory do
+ %{
+ "steps" =>
+ build_list(
+ sequence("ors_segment_json_num_steps", [4, 1, 3, 2]),
+ :ors_directions_step_json
+ )
+ }
+ end
+
+ def ors_directions_json_factory(attrs) do
+ coordinates = Map.get(attrs, :coordinates, [[0, 0], [1, 1], [2, 2]])
+
+ segments =
+ Map.get_lazy(attrs, :segments, fn ->
+ build_list(3, :ors_directions_segment_json)
+ end)
+
+ %{
+ "features" => [
+ %{
+ "geometry" => %{"coordinates" => coordinates},
+ "properties" => %{
+ "segments" => segments
+ }
+ }
+ ]
+ }
+ end
end