diff --git a/.github/workflows/publish.yml b/.github/workflows/release.yml similarity index 84% rename from .github/workflows/publish.yml rename to .github/workflows/release.yml index a0b0de2..b08b358 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,5 @@ -name: publish +name: release +run-name: Release ${{ inputs.VERSION }} (pre-release - ${{ inputs.IS_PRE_RELEASE }}) by @${{ github.actor }} from ${{ github.ref_name }} on: workflow_dispatch: @@ -79,7 +80,7 @@ jobs: run: python -m build working-directory: ./nextplot - publish: # This job is used to publish the release to PyPI/TestPyPI + release: # This job is used to publish the release to PyPI/TestPyPI runs-on: ubuntu-latest needs: bump strategy: @@ -126,3 +127,13 @@ jobs: with: repository-url: https://test.pypi.org/legacy/ packages-dir: ./dist + + notify: + runs-on: ubuntu-latest + needs: release + if: ${{ needs.release.result == 'success' && inputs.IS_PRE_RELEASE == false }} + steps: + - name: notify slack + run: | + export DATA="{\"text\":\"Release notification - nextplot ${{ inputs.VERSION }} (see / )\"}" + curl -X POST -H 'Content-type: application/json' --data "$DATA" ${{ secrets.SLACK_URL_MISSION_CONTROL }} diff --git a/examples/README.md b/examples/README.md index a6bcc05..6b26e1c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -86,14 +86,43 @@ This can be changed via the `--output_image` & `--output_map` parameters. The map plot should look like [this](https://nextmv-io.github.io/nextplot/plots/dortmund-route): ![dortmund-route.json.html.png](https://nextmv-io.github.io/nextplot/plots/dortmund-route/dortmund-route.json.html.png) -## Route plotting with routingkit support +## Route plotting with OSRM support -Next, we're gonna plot routes using the road network. We do this with the -support of [go-routingkit](go-routingkit). +Next, we will plot routes using the road network. We do this with the support of +[OSRM][osrm]. Make sure a server with a suitable region and profile is running. -### Pre-requisites +### Pre-requisites for OSRM -1. Install [go-routingkit](go-routingkit) standalone: +1. Spin up an OSRM server with a suitable region and profile. Follow the + [steps][osrm-install] provided by OSRM to get started. + +### Plot route paths via OSRM + +The command is similar to the one above, but specifies some extra options (refer +to the full list [below](#additional-information)). The `osrm_host` option +activates OSRM driven plotting. + +```bash +nextplot route \ + --input_route data/kyoto-route.json \ + --jpath_route "vehicles[*].route" \ + --jpath_x "position.lon" \ + --jpath_y "position.lat" \ + --output_map kyoto-route.html \ + --output_image kyoto-route.png \ + --osrm_host http://localhost:5000 +``` + +## Route plotting with RoutingKit support + +Another option to plot routes is to use the [go-routingkit][go-rk] library which +comes with a standalone binary. This approach does not need a running server, +but takes longer to compute the routes (as it needs to preprocess the osm file +on each run). + +### Pre-requisites for RoutingKit + +1. Install [go-routingkit][go-rk] standalone: ```bash go install github.com/nextmv-io/go-routingkit/cmd/routingkit@latest @@ -106,7 +135,7 @@ support of [go-routingkit](go-routingkit). wget -N http://download.geofabrik.de/asia/japan/kansai-latest.osm.pbf ``` -### Plot route paths +### Plot route paths via RoutingKit The command is similar to the one above, but specifies some extra options (refer to the full list [below](#additional-information)). The `rk_osm` option @@ -299,6 +328,9 @@ handle certain data formats. Find an outline of these options here: - `--stats_file `: If provided, statistics will be written to the given file in addition to stdout. +- `osrm_host` (route only): + Host of the OSRM server to be used for routing. If provided, routes will be + generated via OSRM. Example: `http://localhost:5000`. - `rk_bin` (route only): Path to the [go-routingkit][go-rk] standalone binary. Alternatively, `routingkit` command will be used at default (requires go-routingkit @@ -315,5 +347,7 @@ handle certain data formats. Find an outline of these options here: [go-rk]: https://github.com/nextmv-io/go-routingkit/tree/stable/cmd/routingkit [go-rk-install]: https://github.com/nextmv-io/go-routingkit/tree/stable/cmd/routingkit#install +[osrm]: https://project-osrm.org/ +[osrm-install]: https://github.com/Project-OSRM/osrm-backend?tab=readme-ov-file#quick-start [custom-layers]: http://leaflet-extras.github.io/leaflet-providers/preview/ [folium-tiles]: https://deparkes.co.uk/2016/06/10/folium-map-tiles/ diff --git a/examples/gallery/README.md b/examples/gallery/README.md index 3d8dff6..165c8f8 100644 --- a/examples/gallery/README.md +++ b/examples/gallery/README.md @@ -121,8 +121,10 @@ suitable region file via: wget -N http://download.geofabrik.de/north-america/us/texas-latest.osm.pbf ``` -This route plot uses routingkit for plotting road paths. Furthermore, unassigned -points are plotted in addition to the route stops. +This route plot uses routingkit for plotting road paths. Alternatively, spin up +a local OSRM server and use the `--osrm_host` flag to use it (see +[osrm-steps][osrm-steps]). Furthermore, unassigned points are plotted in +addition to the route stops. ```bash nextplot route \ @@ -270,3 +272,5 @@ Interactive result: [link](https://nextmv-io.github.io/nextplot/gallery/fleet-cl Image result: ![fleet-cloud-comparison.png](https://nextmv-io.github.io/nextplot/gallery/fleet-cloud-comparison/fleet-cloud-comparison.png) + +[osrm-steps]: ../README.md#route-plotting-with-osrm-support diff --git a/nextplot/__about__.py b/nextplot/__about__.py index c606576..b67ea67 100644 --- a/nextplot/__about__.py +++ b/nextplot/__about__.py @@ -1 +1 @@ -__version__ = "v0.1.5" +__version__ = "v0.1.8" diff --git a/nextplot/geojson.py b/nextplot/geojson.py index 113781f..371155c 100644 --- a/nextplot/geojson.py +++ b/nextplot/geojson.py @@ -104,14 +104,15 @@ def parse( Parses the geojson data object(s) from the file(s). """ # Load json data - content_geojson, _ = common.load_data(input_geojson, "") + content, _ = common.load_data(input_geojson, "") + json_content = json.loads(content) # Extract geojsons if jpath_geojson: expression = jsonpath_ng.parse(jpath_geojson) - geojsons = [json.loads(match.value) for match in expression.find(content_geojson)] + geojsons = [match.value for match in expression.find(json_content)] else: - geojsons = [json.loads(content_geojson)] + geojsons = [json_content] return geojsons diff --git a/nextplot/main.py b/nextplot/main.py index d4a85f5..2f53ecc 100755 --- a/nextplot/main.py +++ b/nextplot/main.py @@ -83,6 +83,7 @@ def entry_point(): weight_points=args.weight_points, no_points=args.no_points, start_end_markers=args.start_end_markers, + osrm_host=args.osrm_host, rk_osm=args.rk_osm, rk_bin=args.rk_bin, rk_profile=args.rk_profile, diff --git a/nextplot/osrm.py b/nextplot/osrm.py new file mode 100644 index 0000000..c45b5b0 --- /dev/null +++ b/nextplot/osrm.py @@ -0,0 +1,137 @@ +import dataclasses +import sys +import urllib.parse + +import polyline +import requests + +from nextplot import common, types + +TRAVEL_SPEED = 10 # assuming 10m/s travel speed for missing segments and snapping + + +@dataclasses.dataclass +class OsrmRouteRequest: + positions: list[types.Position] + + +@dataclasses.dataclass +class OsrmRouteResponse: + paths: list[list[types.Position]] + distances: list[float] + durations: list[float] + zero_distance: bool = False + no_route: bool = False + + +def query_route( + endpoint: str, + route: OsrmRouteRequest, +) -> OsrmRouteResponse: + """ + Queries a route from the OSRM server. + """ + # Encode positions as polyline string to better handle large amounts of positions + polyline_str = polyline.encode([(p.lat, p.lon) for p in route.positions]) + + # Assemble request + url_base = urllib.parse.urljoin(endpoint, "route/v1/driving/") + url = urllib.parse.urljoin(url_base, f"polyline({polyline_str})?overview=full&geometries=polyline&steps=true") + + # Query OSRM + try: + response = requests.get(url) + # If no route was found, use as-the-crow-flies fallback + if response.status_code == 400 and response.json()["code"] == "NoRoute": + print( + f"Warning: OSRM was unable to find a route for {[(p.lat, p.lon) for p in route.positions]}" + + "(lat,lon ordering), using as-the-crow-flies fallback" + ) + paths, distances, durations = [], [], [] + for f, t in zip(route.positions, route.positions[1:], strict=False): + paths.append( + [types.Position(lon=f.lon, lat=f.lat, desc=None), types.Position(lon=t.lon, lat=t.lat, desc=None)] + ) + distances.append(common.haversine(f, t)) + durations.append(common.haversine(f, t) / TRAVEL_SPEED) + return OsrmRouteResponse(paths=paths, distances=distances, durations=durations, no_route=True) + # Make sure we are not getting an error + response.raise_for_status() + except requests.exceptions.RequestException as e: + print(f"Error querying OSRM at {url_base}:", e) + if response: + print(response.text) + sys.exit(1) + result = response.json() + if result["code"] != "Ok": + raise Exception("OSRM returned an error:", result["message"]) + if len(result["routes"]) == 0: + raise Exception(f"No route found for {route.positions}") + + # Process all legs + all_zero_distances = True + legs, distances, durations = [], [], [] + for idx, leg in enumerate(result["routes"][0]["legs"]): + # Combine all steps into a single path + path = [] + for step in leg["steps"]: + path.extend(polyline.decode(step["geometry"])) + # Remove subsequent identical points + path = [path[0]] + [p for i, p in enumerate(path[1:], 1) if path[i] != path[i - 1]] + # Convert to Position objects + path = [types.Position(lon=lon, lat=lat, desc=None) for lat, lon in path] + # Add start and end + path = [route.positions[idx]] + path + [route.positions[idx + 1]] + # Extract distance and duration + distance = leg["distance"] / 1000.0 # OSRM return is in meters, convert to km + duration = leg["duration"] + # Make sure we are finding any routes + if distance > 0: + all_zero_distances = False + # Add duration for start and end + start_distance = common.haversine(path[0], route.positions[idx]) + end_distance = common.haversine(path[-1], route.positions[idx + 1]) + distance += start_distance + end_distance + duration += start_distance / TRAVEL_SPEED + end_distance / TRAVEL_SPEED + # Append to list + legs.append(path) + distances.append(distance) + durations.append(duration) + + # Warn if number of legs does not match number of positions + if len(legs) != len(route.positions) - 1: + print(f"Warning: number of legs ({len(legs)}) does not match number of positions ({len(route.positions)} - 1)") + + # Extract route + return OsrmRouteResponse(paths=legs, distances=distances, durations=durations, zero_distance=all_zero_distances) + + +def query_routes( + endpoint: str, + routes: list[types.Route], +) -> list[OsrmRouteResponse]: + """ + Queries multiple routes from the OSRM server. + + param str endpoint: URL of the OSRM server. + param list[OsrmRouteRequest] routes: List of routes to query. + + return: List of route results. + """ + + # Query all routes + reqs = [OsrmRouteRequest(positions=route.points) for route in routes] + zero_distance_routes, no_route_routes = 0, 0 + for r, req in enumerate(reqs): + result = query_route(endpoint, req) + routes[r].legs = result.paths + routes[r].leg_distances = result.distances + routes[r].leg_durations = result.durations + if result.zero_distance: + zero_distance_routes += 1 + if result.no_route: + no_route_routes += 1 + if zero_distance_routes > 0: + print(f"Warning: {zero_distance_routes} / {len(routes)} routes have zero distance according to OSRM") + if no_route_routes > 0: + print(f"Warning: {no_route_routes} / {len(routes)} routes could not be found by OSRM") diff --git a/nextplot/route.py b/nextplot/route.py index f9b847a..6c0e645 100644 --- a/nextplot/route.py +++ b/nextplot/route.py @@ -1,10 +1,13 @@ +import dataclasses import json +from collections.abc import Callable import folium +import jsonpath_ng import plotly.graph_objects as go from folium import plugins -from . import common, routingkit, types +from . import common, osrm, routingkit, types # ==================== This file contains route plotting code (mode: 'route') @@ -14,28 +17,19 @@ # ==================== Pre-configured plot profiles +@dataclasses.dataclass class RoutePlotProfile: """ Pre-configured plot profiles for routes. """ - def __init__( - self, - jpath_route: str = "", - jpath_pos: str = "", - jpath_x: str = "", - jpath_y: str = "", - jpath_unassigned: str = "", - jpath_unassigned_x: str = "", - jpath_unassigned_y: str = "", - ): - self.jpath_route = jpath_route - self.jpath_pos = jpath_pos - self.jpath_x = jpath_x - self.jpath_y = jpath_y - self.jpath_unassigned = jpath_unassigned - self.jpath_unassigned_x = jpath_unassigned_x - self.jpath_unassigned_y = jpath_unassigned_y + jpath_route: str = "" + jpath_pos: str = "" + jpath_x: str = "" + jpath_y: str = "" + jpath_unassigned: str = "" + jpath_unassigned_x: str = "" + jpath_unassigned_y: str = "" def __str__(self): return ( @@ -50,6 +44,28 @@ def __str__(self): ) +@dataclasses.dataclass +class MultiRoutePlotProfile: + """ + Multiple pre-configured plot profiles selected via given input tests. + """ + + profiles: list[tuple[Callable[[dict, dict], bool], RoutePlotProfile]] = dataclasses.field(default_factory=list) + fail_message: str = "No suitable profile found for plotting." + + def unwrap(self, content_route: dict, content_pos: dict) -> RoutePlotProfile: + """ + Tests the given data against the profiles and returns the first matching profile. + """ + for test, profile in self.profiles: + if test(content_route, content_pos): + return profile + raise Exception(self.fail_message) + + def __str__(self): + return "MultiRoutePlotProfile(" + ", ".join([f"{p[0]}: {p[1]}" for p in self.profiles]) + ")" + + # ==================== Route mode argument definition @@ -175,6 +191,13 @@ def arguments(parser): action="store_true", help="indicates whether to add start and end markers", ) + parser.add_argument( + "--osrm_host", + type=str, + nargs="?", + default=None, + help="host and port of the OSRM server (e.g. 'http://localhost:5000')", + ) parser.add_argument( "--rk_bin", type=str, @@ -212,14 +235,8 @@ def arguments(parser): def parse( input_route: str, - jpath_route: str, - jpath_unassigned: str, - jpath_unassigned_x: str, - jpath_unassigned_y: str, input_pos: str, - jpath_pos: str, - jpath_x: str, - jpath_y: str, + profile: MultiRoutePlotProfile | RoutePlotProfile, ) -> tuple[list[list[types.Position]], list[list[types.Position]]]: """ Parses the route data from the file(s). @@ -227,6 +244,17 @@ def parse( # Load json data content_route, content_pos = common.load_data(input_route, input_pos) + # Dynamically set profile, if given + if isinstance(profile, MultiRoutePlotProfile): + profile = profile.unwrap(json.loads(content_route), json.loads(content_pos)) + jpath_route = profile.jpath_route + jpath_pos = profile.jpath_pos + jpath_x = profile.jpath_x + jpath_y = profile.jpath_y + jpath_unassigned = profile.jpath_unassigned + jpath_unassigned_x = profile.jpath_unassigned_x + jpath_unassigned_y = profile.jpath_unassigned_y + # Extract routes points = common.extract_position_groups( content_route, @@ -334,7 +362,6 @@ def create_map( route_animation_color: str, start_end_markers: bool, custom_map_tile: list[str], - rk_distance: bool, ) -> folium.Map: """ Plots the given routes on a folium map. @@ -369,8 +396,6 @@ def create_map( omit_end, route_direction, route_animation_color, - 1.0 / 1000.0 if rk_distance else 1.0 / 1000.0, - "km" if rk_distance else "s", ) # Plot points @@ -514,6 +539,7 @@ def plot( weight_points: float, no_points: bool, start_end_markers: bool, + osrm_host: str, rk_osm: str, rk_bin: str, rk_profile: routingkit.RoutingKitProfile, @@ -548,17 +574,7 @@ def plot( profile = nextroute_profile() # Parse data - points, unassigned = parse( - input_route, - profile.jpath_route, - profile.jpath_unassigned, - profile.jpath_unassigned_x, - profile.jpath_unassigned_y, - input_pos, - profile.jpath_pos, - profile.jpath_x, - profile.jpath_y, - ) + points, unassigned = parse(input_route, input_pos, profile) # Quit on no points if len(points) <= 0: @@ -600,8 +616,10 @@ def plot( route.points[i].distance = length route.length = length - # Determine route shapes (if routingkit is available) - if rk_osm: + # Determine route shapes (if osrm or routingkit are available) + if osrm_host: + osrm.query_routes(osrm_host, routes) + elif rk_osm: routingkit.query_routes(rk_bin, rk_osm, routes, rk_profile, rk_distance) # Dump some stats @@ -670,7 +688,6 @@ def plot( route_animation_color, start_end_markers, custom_map_tile, - rk_distance, ) # Save map @@ -685,13 +702,37 @@ def nextroute_profile() -> RoutePlotProfile: """ Returns the nextroute profile. """ - return RoutePlotProfile( - jpath_route="solutions[-1].vehicles[*].route", - jpath_x="stop.location.lon", - jpath_y="stop.location.lat", - jpath_unassigned="solutions[-1].unplanned[*]", - jpath_unassigned_x="location.lon", - jpath_unassigned_y="location.lat", + base_paths = [ + "solutions[-1]", + "solution", + "output.solutions[-1]", + "output.solution", + ] + + def make_path_exists(path): + def path_exists(content_route, path): + matches = jsonpath_ng.parse(path).find(content_route) + return len(list(matches)) > 0 + + return lambda content_route, _: path_exists(content_route, path) + + return MultiRoutePlotProfile( + [ + ( + make_path_exists(p), + RoutePlotProfile( + jpath_route=f"{p}.vehicles[*].route", + jpath_x="stop.location.lon", + jpath_y="stop.location.lat", + jpath_unassigned=f"{p}.unplanned[*]", + jpath_unassigned_x="location.lon", + jpath_unassigned_y="location.lat", + ), + ) + for p in base_paths + ], + "Input data does not match any known profile for nextroute plotting. Routes are expected at one of:\n" + + "\n".join(f"{p}.vehicles[*].route" for p in base_paths), ) @@ -737,15 +778,15 @@ def plot_map_route( omit_end: bool, direction: types.RouteDirectionIndicator = types.RouteDirectionIndicator.none, animation_bg_color: str = "FFFFFF", - rk_factor: float = None, - rk_unit: str = None, ): """ Plots a route on the given map. """ rk_text = "" - if route.legs is not None: - rk_text = f"Route cost (routingkit): {sum(route.leg_costs) * rk_factor:.2f} {rk_unit}
" + if route.leg_distances is not None: + rk_text += f"Route cost (rk/osrm): {sum(route.leg_distances):.2f} km
" + if route.leg_durations is not None: + rk_text += f"Route duration (rk/osrm): {sum(route.leg_durations):.2f} s
" popup_text = folium.Html( "

" + f"Route: {route_idx+1} / {route_count}
" @@ -838,13 +879,23 @@ def statistics( types.Stat("nunassigned", "Unassigned stops", sum([len(g) for g in unassigned])), ] - if all((r.legs is not None) for r in routes): - costs = [sum(r.leg_costs) for r in routes] + if all((r.leg_distances is not None) for r in routes): + costs = [sum(r.leg_distances) for r in routes] + stats.extend( + [ + types.Stat("distances_max", "RK/OSRM distances (max)", max(costs)), + types.Stat("distances_min", "RK/OSRM distances (min)", min(costs)), + types.Stat("distances_avg", "RK/OSRM distances (avg)", sum(costs) / float(len(routes))), + ] + ) + + if all((r.leg_durations is not None) for r in routes): + durations = [sum(r.leg_durations) for r in routes] stats.extend( [ - types.Stat("costs_max", "RK costs (max)", max(costs)), - types.Stat("costs_min", "RK costs (min)", min(costs)), - types.Stat("costs_avg", "RK costs (avg)", sum(costs) / float(len(routes))), + types.Stat("durations_max", "RK/OSRM durations (max)", max(durations)), + types.Stat("durations_min", "RK/OSRM durations (min)", min(durations)), + types.Stat("durations_avg", "RK/OSRM durations (avg)", sum(durations) / float(len(routes))), ] ) diff --git a/nextplot/routingkit.py b/nextplot/routingkit.py index 3690d53..56585b5 100644 --- a/nextplot/routingkit.py +++ b/nextplot/routingkit.py @@ -83,7 +83,8 @@ def query_routes( # Clear any previously existing information for route in routes: route.legs = None - route.leg_costs = None + route.leg_distances = None + route.leg_durations = None # Add results to routes for i, path in enumerate(paths): @@ -115,13 +116,22 @@ def query_routes( end_cost /= travel_speed cost += start_cost + end_cost + # RK uses milliseconds and meters, convert to seconds and kilometers (same factor) + cost /= 1000.0 + # Add leg to route if route.legs is None: route.legs = [leg] - route.leg_costs = [cost] + if distance: + route.leg_distances = [cost] + else: + route.leg_durations = [cost] else: route.legs.append(leg) - route.leg_costs.append(cost) + if distance: + route.leg_distances.append(cost) + else: + route.leg_durations.append(cost) def query( diff --git a/nextplot/types.py b/nextplot/types.py index 9ba0cfd..1975223 100644 --- a/nextplot/types.py +++ b/nextplot/types.py @@ -105,7 +105,8 @@ class Route: def __init__(self, points: list[Position]): self.points = points self.legs = None - self.leg_costs = 0 + self.leg_distances = None + self.leg_durations = None def to_points(self, omit_start: bool, omit_end: bool) -> list[Position]: """ diff --git a/pyproject.toml b/pyproject.toml index 16291d4..6789fcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "kaleido>=0.2.1", "numpy>=1.22.3", "plotly>=5.7.0", + "polyline>=2.0.2", "scipy>=1.8.0", ] description = "Tools for plotting routes, clusters and more from JSON" diff --git a/requirements-dev.txt b/requirements-dev.txt index eda8ad4..047fdf4 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -5,6 +5,7 @@ jsonpath_ng==1.6.1 kaleido==0.2.1 numpy==1.26.4 plotly==5.21.0 +polyline==2.0.2 scipy==1.13.0 pytest==7.1.1 imagehash==4.3.1 diff --git a/tests/test_cli.py b/tests/test_cli.py index 621d2ad..93d522f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -480,6 +480,23 @@ def test_map_plot_cli_geojson(): _run_geojson_test(test) +def test_map_plot_cli_geojson_nested(): + test = GeoJSONTest( + "geojson", + [ + "geojson", + "--input_geojson", + os.path.join(DATA_DIR, "geojson-nested-data.json"), + "--jpath_geojson", + "assets[*].content", + ], + os.path.join(OUTPUT_DIR, "geojson-nested-data.json.map.html"), + os.path.join(DATA_DIR, "geojson-nested-data.json.golden"), + os.path.join(DATA_DIR, "geojson-nested-data.json.map.html.golden"), + ) + _run_geojson_test(test) + + def test_progression_plot_cli_fleet_cloud_comparison(): test = ProgressionTest( "fleet-cloud-comparison", @@ -507,5 +524,6 @@ def test_progression_plot_cli_fleet_cloud_comparison(): test_map_plot_cli_paris_point() test_map_plot_cli_paris_route_indexed() test_map_plot_cli_geojson() + test_map_plot_cli_geojson_nested() test_progression_plot_cli_fleet_cloud_comparison() print("Everything passed") diff --git a/tests/testdata/geojson-nested-data.json b/tests/testdata/geojson-nested-data.json new file mode 100644 index 0000000..dc234b9 --- /dev/null +++ b/tests/testdata/geojson-nested-data.json @@ -0,0 +1,39 @@ +{ + "assets": [ + { + "type": "geojson", + "content": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-74.076, 4.598] + }, + "properties": {} + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-75.1649, 39.9525] + }, + "properties": {} + } + ] + } + }, + { + "type": "geojson", + "content": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [7.6281, 51.962] + }, + "properties": {} + } + } + ] +} diff --git a/tests/testdata/geojson-nested-data.json.golden b/tests/testdata/geojson-nested-data.json.golden new file mode 100644 index 0000000..e69de29 diff --git a/tests/testdata/geojson-nested-data.json.map.html.golden b/tests/testdata/geojson-nested-data.json.map.html.golden new file mode 100644 index 0000000..74b6708 --- /dev/null +++ b/tests/testdata/geojson-nested-data.json.map.html.golden @@ -0,0 +1,226 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ + + + \ No newline at end of file