{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# GPX Viewer\n", "\n", "This app lets you to display a track from a GPX file recorded with a GPS device." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import datetime\n", "import json\n", "import os\n", "from io import StringIO\n", "from statistics import mean\n", "\n", "import gpxpy\n", "import srtm\n", "\n", "from bqplot import Axis, Figure, Lines, LinearScale\n", "from bqplot.interacts import IndexSelector\n", "from ipyleaflet import basemaps, FullScreenControl, LayerGroup, Map, MeasureControl, Polyline, Marker, CircleMarker, WidgetControl\n", "from ipywidgets import Button, HTML, HBox, VBox, Checkbox, FileUpload, Label, Output, IntSlider, Layout, Image, link" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "tools = [\"voila\", \"ipyleaflet\", \"ipywidgets\", \"bqplot\"]\n", "logos = []\n", "for tool in tools:\n", " with open(f'./img/{tool}.png', 'rb') as f:\n", " image = f.read()\n", " img = Image(value=image, format='png',layout=Layout(padding='10px'))\n", " logos.append(img)\n", "HBox([Label(value='Powered by:')] + logos, layout=Layout(flex_flow='row', align_items='center'))" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# create the output widget to place the results\n", "out = Output()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def parse_data(file):\n", " \"\"\"\n", " Parse a GPX file and add elevations\n", " \"\"\"\n", " gpx = gpxpy.parse(file)\n", " elevation_data = srtm.get_data()\n", " elevation_data.add_elevations(gpx, smooth=True)\n", " return gpx" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def plot_map(gpx):\n", " \"\"\"\n", " Plot the GPS trace on a map\n", " \"\"\"\n", " points = [p.point for p in gpx.get_points_data(distance_2d=True)]\n", " mean_lat = mean(p.latitude for p in points)\n", " mean_lng = mean(p.longitude for p in points)\n", "\n", " # create the map\n", " m = Map(center=(mean_lat, mean_lng), zoom=12, basemap=basemaps.Stamen.Terrain)\n", "\n", " # show trace\n", " line = Polyline(locations=[[[p.latitude, p.longitude] for p in points],],\n", " color = \"red\", fill=False)\n", " m.add_layer(line)\n", "\n", " # add markers\n", " waypoints = [\n", " Marker(location=(point.latitude, point.longitude), title=point.name,\n", " popup=HTML(value=point.name), draggable=False)\n", " for point in gpx.waypoints\n", " ]\n", " waypoints_layer = LayerGroup(layers=waypoints)\n", " m.add_layer(waypoints_layer)\n", " \n", " # add a checkbox to show / hide waypoints\n", " waypoints_checkbox = Checkbox(value=True, description='Show Waypoints')\n", " \n", " def update_visible(change):\n", " for p in waypoints:\n", " p.visible = change['new']\n", " \n", " waypoints_checkbox.observe(update_visible, 'value')\n", " waypoint_control = WidgetControl(widget=waypoints_checkbox, position='bottomright')\n", " m.add_control(waypoint_control)\n", " \n", " # enable full screen mode\n", " m.add_control(FullScreenControl())\n", " \n", " # add measure control\n", " measure = MeasureControl(\n", " position='bottomleft',\n", " active_color = 'orange',\n", " primary_length_unit = 'kilometers'\n", " )\n", " m.add_control(measure)\n", " \n", " return m" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def plot_stats(gpx):\n", " \"\"\"\n", " Compute statistics for a given trace\n", " \"\"\"\n", " lowest, highest = gpx.get_elevation_extremes()\n", " uphill, downhill = gpx.get_uphill_downhill()\n", " points = gpx.get_points_data(distance_2d=True)\n", " \n", " _, distance_from_start, *rest = points[-1]\n", " \n", " stat_layout = Layout(margin=\"10px\", padding=\"10px\", border=\"1px solid black\",\n", " flex_flow='column', align_items='center')\n", " \n", " start_time = gpx.get_time_bounds().start_time\n", " duration = gpx.get_duration()\n", " stats = [\n", " ('Date', start_time.strftime(\"%Y-%m-%d\") if start_time else '-'),\n", " ('Distance', f\"{round(distance_from_start / 1000, 2)} km\"),\n", " ('Duration', str(datetime.timedelta(seconds=duration)) if duration else '-'),\n", " ('Lowest', f\"{int(lowest)} m\"),\n", " ('Highest', f\"{int(highest)} m\"),\n", " ('Uphill', f\"{int(uphill)} m\"),\n", " ('Downhill', f\"{int(downhill)} m\"),\n", " ]\n", " \n", " stats_formatted = [\n", " VBox([\n", " HTML(value=f\"{title}\"),\n", " Label(value=value)\n", " ], layout=stat_layout)\n", " for title, value in stats\n", " ]\n", " \n", " return HBox(stats_formatted, layout=Layout(flex_flow='row', align_items='center'))" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def plot_elevation(gpx):\n", " \"\"\"\n", " Return an elevation graph for the given gpx trace\n", " \"\"\"\n", " points = gpx.get_points_data(distance_2d=True)\n", " px = [p.distance_from_start / 1000 for p in points]\n", " py = [p.point.elevation for p in points]\n", " \n", " x_scale, y_scale = LinearScale(), LinearScale()\n", " x_scale.allow_padding = False\n", " x_ax = Axis(label='Distance (km)', scale=x_scale)\n", " y_ax = Axis(label='Elevation (m)', scale=y_scale, orientation='vertical')\n", " \n", " lines = Lines(x=px, y=py, scales={'x': x_scale, 'y': y_scale})\n", " \n", " elevation = Figure(title='Elevation Chart', axes=[x_ax, y_ax], marks=[lines])\n", " elevation.layout.width = 'auto'\n", " elevation.layout.height = 'auto'\n", " elevation.layout.min_height = '300px'\n", "\n", " elevation.interaction = IndexSelector(scale=x_scale)\n", " \n", " return elevation" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def link_trace_elevation(trace, elevation, gpx, debug):\n", " \"\"\"\n", " Link the trace the elevation graph.\n", " Changing the selection on the elevation will update the\n", " marker on the map\n", " \"\"\"\n", " points = gpx.get_points_data(distance_2d=True)\n", " _, distance_from_start, *rest = points[-1]\n", " n_points = len(points)\n", " \n", " def find_point(distance):\n", " \"\"\"\n", " Find a point given the distance\n", " \"\"\"\n", " progress = min(1, max(0, distance / distance_from_start))\n", " position = int(progress * (n_points - 1))\n", " return points[position].point\n", " \n", " # add a checkbox to auto center\n", " autocenter = Checkbox(value=False, description='Auto Center')\n", " autocenter_control = WidgetControl(widget=autocenter, position='bottomright')\n", " trace.add_control(autocenter_control)\n", " \n", " # mark the current position on the map\n", " start = find_point(0)\n", " marker = CircleMarker(visible=False, location=(start.latitude, start.longitude),\n", " radius=10, color=\"green\", fill_color=\"green\")\n", " trace.add_layer(marker)\n", " \n", " brushintsel = elevation.interaction\n", " \n", " def update_range(change):\n", " \"\"\"\n", " Update the position on the map when the elevation\n", " graph selector changes\n", " \"\"\"\n", " if brushintsel.selected.shape != (1,):\n", " return\n", " marker.visible = True\n", " selected = brushintsel.selected * 1000 # convert from km to m\n", " point = find_point(selected)\n", " marker.location = (point.latitude, point.longitude)\n", " \n", " if autocenter.value:\n", " trace.center = marker.location\n", " \n", " position = max(0, int((selected / distance_from_start) * len(points)))\n", " \n", " brushintsel.observe(update_range, 'selected')" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def plot_gpx(gpx_file):\n", " gpx = parse_data(gpx_file)\n", " \n", " stats = plot_stats(gpx)\n", " trace = plot_map(gpx)\n", " elevation = plot_elevation(gpx)\n", " debug = Label(value='')\n", " \n", " display(stats)\n", " display(trace)\n", " display(elevation)\n", " display(debug)\n", " \n", " link_trace_elevation(trace, elevation, gpx, debug)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def show_uploader():\n", " uploader = FileUpload(accept='.gpx', multiple=False)\n", "\n", " def handle_upload(change):\n", " # keep only the last file\n", " # TODO: check if this should be fixed in FileUpload widget\n", " # when multiple=False\n", " *_, (_, f) = change['new'].items()\n", " gpx_content = f['content'].decode('utf-8')\n", " out.clear_output()\n", " with StringIO(gpx_content) as gpx_file:\n", " with out:\n", " plot_gpx(gpx_file)\n", "\n", " uploader.observe(handle_upload, names='value')\n", "\n", " display(uploader)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def show_examples():\n", " example_folder = \"./examples\"\n", " examples = [f for f in os.listdir(example_folder) if f.endswith('.gpx')]\n", " \n", " def create_example(name):\n", " filename = os.path.join(example_folder, name)\n", " \n", " @out.capture()\n", " def on_example_clicked(change):\n", " out.clear_output()\n", " with open(filename) as f:\n", " with out:\n", " plot_gpx(f)\n", " \n", " button = Button(description=os.path.splitext(name)[0])\n", " button.on_click(on_example_clicked)\n", " return button\n", "\n", " \n", " buttons = [create_example(example) for example in examples]\n", " line = HBox(buttons, layout=Layout(flex_flow='row', align_items='center'))\n", " display(line)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "show_uploader()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Looking for a GPX file to upload? Many are available on websites such as [GPSies](https://www.gpsies.com), [Wandermap](http://www.wandermap.net), [Wikiloc](https://www.wikiloc.com) or [MapMyRide](https://www.mapmyride.com).\n", "\n", "Or try with the following examples:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "show_examples()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# To test without the file uploader\n", "# with open('./trace.gpx') as f:\n", "# plot_gpx(f)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "out" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.10.6" }, "title": "GPX Viewer" }, "nbformat": 4, "nbformat_minor": 4 }