~ihabunek/triglav

f1d6e83eca7a435886a30c077f08ff30f6c813dd — Ivan Habunek 8 months ago 699daff
Improve mapping, draw exact route
M assets/js/routes.js => assets/js/routes.js +39 -7
@@ 1,5 1,13 @@
import "leaflet/dist/leaflet.css"
import { Map, TileLayer, Marker, Icon, FeatureGroup } from "leaflet"
import {
  CircleMarker,
  Control,
  FeatureGroup,
  Icon,
  Map,
  Marker,
  TileLayer,
} from "leaflet"

const map = new Map("routes-map").setView([45.8, 16], 10)



@@ 10,18 18,42 @@ const tiles = new TileLayer(tilesUrl, { attribution })

tiles.addTo(map)

const ways = JSON.parse(document.getElementById("ways-geojson").innerHTML)
const stops = JSON.parse(document.getElementById("stops-json").innerHTML)
const icon = new Icon({ iconUrl: "/images/stop.svg", iconSize: [14, 14] })

const waysGeoJSON = JSON.parse(document.getElementById("ways-geojson").innerHTML)
L.geoJSON(waysGeoJSON).addTo(map)

const markers = []
for (const stop of stops) {
  const marker = new Marker(stop, { icon: icon, title: stop.name })
  markers.push(marker)
}

const group = new FeatureGroup(markers)
group.addTo(map)
map.fitBounds(group.getBounds())
const zetStops = new FeatureGroup(markers).addTo(map)

const layers = { "ZET Stops": zetStops }

for (const name of Object.keys(ways)) {
  const way = L.geoJSON(ways[name], {
    style: feature => ({
      color: feature.properties.color
    }),
    pointToLayer: (feature, latlng) => {
      return new CircleMarker(latlng, {
        radius: 8,
        title: feature.properties.name,
      }).bindPopup(`
        <b>${feature.properties.name}</b><br/>
        Relation: ${feature.properties.relation_id}<br/>
        Sequence: ${feature.properties.sequence_id}<br/>
        GTFS ID: ${feature.properties.gtfs_stop_id || "MISSING"}
      `)
    }
  })

  way.addTo(map)
  layers[name] = way
}

new Control.Layers({}, layers).addTo(map)

map.fitBounds(zetStops.getBounds())

A lib/triglav/geo_json.ex => lib/triglav/geo_json.ex +68 -0
@@ 0,0 1,68 @@
defmodule Triglav.GeoJSON do
  alias Triglav.Schemas.Osmosis.{Way, Node}

  def to_feature(object, properties \\ %{})

  def to_feature(%Way{} = way, properties) do
    properties = Map.merge(%{id: way.id, type: "W"}, properties)
    feature(geometry(way), properties)
  end

  def to_feature(%Node{} = node, properties) do
    properties = Map.merge(%{id: node.id, type: "N"}, properties)

    feature(geometry(node), properties)
  end

  defp geometry(%Node{} = node) do
    %{
      type: "Point",
      coordinates: Tuple.to_list(node.geom.coordinates)
    }
  end

  defp geometry(%Way{} = way) do
    %{
      type: "LineString",
      coordinates: Enum.map(way.linestring.coordinates, &Tuple.to_list/1)
    }
  end

  def feature(geometry, properties \\ %{}) do
    %{
      type: "Feature",
      properties: properties,
      geometry: geometry
    }
  end

  def feature_collection(features) do
    %{
      type: "FeatureCollection",
      features: features
    }
  end

  @spec nodes_to_linestring([Node.t()]) :: map()
  def nodes_to_linestring(nodes) do
    coordinates = Enum.map(nodes, &Tuple.to_list(&1.geom.coordinates))

    %{
      type: "LineString",
      coordinates: coordinates
    }
  end

  @spec ways_to_multilinestring([Way.t()]) :: map()
  def ways_to_multilinestring(ways) do
    coordinates =
      Enum.map(ways, fn way ->
        Enum.map(way.linestring.coordinates, &Tuple.to_list/1)
      end)

    %{
      type: "MultiLineString",
      coordinates: coordinates
    }
  end
end

A lib/triglav/osm.ex => lib/triglav/osm.ex +37 -0
@@ 0,0 1,37 @@
defmodule Triglav.Osm do
  alias Triglav.Repo
  alias Triglav.Schemas.Osmosis.Node
  alias Triglav.Schemas.Osmosis.Relation
  alias Triglav.Schemas.Osmosis.Way

  import Ecto.Query

  @doc """
  Returns a list of nodes with given ids, ordered the same as the given ids.
  """
  @spec list_ordered_nodes([integer()]) :: [Node.t()]
  def list_ordered_nodes(node_ids) do
    node_map =
      from(n in Node, where: n.id in ^node_ids)
      |> Repo.all()
      |> Map.new(&{&1.id, &1})

    Enum.map(node_ids, &Map.get(node_map, &1))
  end

  def list_ordered_ways(%Relation{} = relation) do
    way_ids =
      relation
      |> Repo.preload(:members)
      |> Map.get(:members)
      |> Enum.filter(&(&1.member_type == "W" and &1.member_role == ""))
      |> Enum.map(& &1.member_id)

    ways =
      from(w in Way, where: w.id in ^way_ids)
      |> Repo.all()
      |> Map.new(&{&1.id, &1})

    Enum.map(way_ids, &Map.get(ways, &1))
  end
end

A lib/triglav/osm/router.ex => lib/triglav/osm/router.ex +127 -0
@@ 0,0 1,127 @@
defmodule Triglav.Osm.Router do
  alias Triglav.Osm
  alias Triglav.Repo
  alias Triglav.Schemas.Osmosis.{Node, Relation, Way}

  import Ecto.Query

  defmodule AnnotatedWay do
    defstruct [:way, :direction, :node_ids]

    def forward(way) do
      %AnnotatedWay{
        way: way,
        direction: :forward,
        node_ids: way.node_ids
      }
    end

    def backward(way) do
      %AnnotatedWay{
        way: way,
        direction: :backward,
        node_ids: Enum.reverse(way.node_ids)
      }
    end

    def custom(way, node_ids) do
      %AnnotatedWay{
        way: way,
        direction: :custom,
        node_ids: node_ids
      }
    end
  end

  @doc """
  Given a list of ordered ways returns an ordered list of nodes contained in them.

  TODO: This could be changed to return a list of coordinates read from
  `way.linestring` so nodes don't need to be loaded at all.
  """
  @spec list_nodes([Way.t()]) :: {:ok, [Node.t()]} | {:error, any()}
  def list_nodes(ordered_ways) do
    with {:ok, annotated_ways} <- annotate_ways(ordered_ways) do
      {:ok,
       annotated_ways
       |> Enum.map(& &1.node_ids)
       |> Enum.concat()
       |> Enum.dedup()
       |> Osm.list_ordered_nodes()}
    end
  end

  defp annotate_ways(ways), do: annotate_ways(ways, [])

  defp annotate_ways([], annotated_ways), do: {:ok, Enum.reverse(annotated_ways)}

  # For the first way in relation, check out the second way to determine direction
  defp annotate_ways(ways, []) do
    [w1, w2 | _] = ways

    cond do
      List.last(w1.node_ids) in w2.node_ids ->
        annotate_ways(tl(ways), [AnnotatedWay.forward(w1)])

      List.first(w1.node_ids) in w2.node_ids ->
        annotate_ways(tl(ways), [AnnotatedWay.backward(w1)])

      true ->
        {:error, {:ways_not_connected, w1, w2}}
    end
  end

  defp annotate_ways([w2 | ways], [annotated_w1 | _] = annotated_ways) do
    if Way.is_whole_roundabout?(w2) do
      with {:ok, node_ids} <- get_roundabout_segment(w2, annotated_w1, hd(ways)) do
        annotate_ways(ways, [AnnotatedWay.custom(w2, node_ids) | annotated_ways])
      end
    else
      common_node_id = List.last(annotated_w1.node_ids)

      cond do
        common_node_id == List.first(w2.node_ids) ->
          annotate_ways(ways, [AnnotatedWay.forward(w2) | annotated_ways])

        common_node_id == List.last(w2.node_ids) ->
          annotate_ways(ways, [AnnotatedWay.backward(w2) | annotated_ways])

        true ->
          {:error, {:ways_not_connected, annotated_w1.way, w2}}
      end
    end
  end

  @spec get_roundabout_segment(Way.t(), AnnotatedWay.t(), Way.t()) ::
          {:ok, [integer()]} | {:error, any()}
  defp get_roundabout_segment(roundabout_way, annotated_prev_way, next_way) do
    entry_node_id = List.last(annotated_prev_way.node_ids)

    first_node_id = List.first(next_way.node_ids)
    last_node_id = List.last(next_way.node_ids)

    exit_node_id =
      cond do
        first_node_id in roundabout_way.node_ids -> first_node_id
        last_node_id in roundabout_way.node_ids -> last_node_id
        true -> nil
      end

    cond do
      entry_node_id not in roundabout_way.node_ids ->
        {:error, {:ways_not_connected, annotated_prev_way.way, roundabout_way}}

      is_nil(exit_node_id) ->
        {:error, {:ways_not_connected, roundabout_way, next_way}}

      true ->
        node_ids =
          roundabout_way.node_ids
          |> Stream.cycle()
          |> Stream.drop_while(&(&1 != entry_node_id))
          |> Enum.take_while(&(&1 != exit_node_id))

        {:ok, node_ids ++ [exit_node_id]}
    end
  end
end

M lib/triglav_web/controllers/zet/routes_controller.ex => lib/triglav_web/controllers/zet/routes_controller.ex +62 -24
@@ 1,12 1,26 @@
defmodule TriglavWeb.Zet.RoutesController do
  use TriglavWeb, :controller

  alias Triglav.GeoJSON
  alias Triglav.Import.Geofabrik
  alias Triglav.Osm
  alias Triglav.Osm.Router
  alias Triglav.Schemas.Osmosis.Relation
  alias Triglav.Zet.Errors
  alias Triglav.Zet.Gtfs
  alias Triglav.Zet.Osmosis

  @error_color "#eb4d4b"

  @route_colors [
    light_blue: "#3498db",
    purple: "#8e44ad",
    green: "#27ae60",
    dark_blue: "#3742fa",
    orange: "#d35400",
    dark_gray: "#2c3e50"
  ]

  def index(conn, _params) do
    routes = Gtfs.list_routes()
    errors = Errors.list_unresolved() |> Enum.group_by(& &1.route_id)


@@ 96,9 110,7 @@ defmodule TriglavWeb.Zet.RoutesController do
      |> Enum.map(&Map.take(&1, [:id, :name, :lat, :lon]))
      |> Jason.encode!()

    ways_geojson =
      ways_geojson_feature_collection(relations)
      |> Jason.encode!()
    ways_geojson = routes_feature_collecton(relations, platform_members) |> Jason.encode!()

    render(conn, "detail.html",
      conn: conn,


@@ 114,29 126,55 @@ defmodule TriglavWeb.Zet.RoutesController do
    )
  end

  defp ways_geojson_feature_collection(relations) do
    features =
      for {relation, idx} <- Enum.with_index(relations), Relation.is_route(relation) do
        coordinates =
          Osmosis.list_ways(relations, geojson: true)
          |> Enum.map(&(&1.geojson |> Jason.decode!() |> Map.get("coordinates")))

        %{
          type: "Feature",
          properties: %{
            idx: idx
          },
          geometry: %{
            type: "MultiLineString",
            coordinates: coordinates
          }
        }
  defp routes_feature_collecton(relations, platform_members) do
    colors = @route_colors |> Enum.map(fn {_, v} -> v end) |> Stream.cycle()

    relations
    |> Enum.filter(&Relation.is_route/1)
    |> Enum.zip(colors)
    |> Map.new(fn {relation, color} ->
      name = "#{relation.tags["name"]} (##{relation.id})"
      platforms = Map.get(platform_members, relation.id)

      route_feature = route_feature(relation, color)
      platform_features = Enum.map(platforms, &platform_feature(&1, color))
      collection = GeoJSON.feature_collection([route_feature | platform_features])

      {name, collection}
    end)
  end

  defp platform_feature(member, color) do
    stop_id = member.member.tags["gtfs:stop_id"]
    platform_color = if stop_id, do: color, else: @error_color

    GeoJSON.to_feature(member.member, %{
      name: member.member.tags["name"],
      relation_id: member.relation_id,
      sequence_id: member.sequence_id,
      gtfs_stop_id: stop_id,
      color: platform_color
    })
  end

  defp route_feature(relation, color) do
    ordered_ways = Osm.list_ordered_ways(relation)

    route_geometry =
      case Router.list_nodes(ordered_ways) do
        {:ok, nodes} ->
          GeoJSON.nodes_to_linestring(nodes)

        # fallback if routing fails, e.g. because of a broken route
        {:error, _} ->
          GeoJSON.ways_to_multilinestring(ordered_ways)
      end

    %{
      type: "FeatureCollection",
      features: features
    }
    GeoJSON.feature(route_geometry, %{
      id: relation.tags["id"],
      type: relation.tags["type"],
      color: color
    })
  end

  defp make_hierarchy(relations) do