A park4night/OsmAnd POI export (e.g. a p4n2gpx area export) tags every waypoint with a category and a per-category color/icon in its <extensions> block, all of which was previously thrown away — every waypoint rendered as the same plain red dot, unreadable once a file has a few thousand of them. Maptool::Gpx now captures category/color/icon (waypoint_style), matched by namespace URI rather than local element name: a p4n2gpx file carries both <osmand:icon> and <locus:icon> side by side, and REXML strips prefixes from Element#name, so both report as just "icon" — matching on that alone was silently picking the (useless) Locus drawable path instead of the OsmAnd category key. Regression test added for this. On the map: waypoints are colored by category (falling back to the plain track color for anything uncategorized), a small hand-drawn icon set (public/js/icons.js's POI_ICONS, rasterized to SDF sprites at runtime) draws a glyph on top for the categories it covers, the points source is clustered so a dense export collapses into count bubbles instead of a solid blob, and a legend in the side panel lists whichever categories are currently visible. Splitting each file's GeoJSON into a lines source and a points source (rather than one mixed source with a geometry-type filter per layer) was needed for clustering, since MapLibre's cluster support only understands a source made entirely of points.
131 lines
6.0 KiB
Ruby
131 lines
6.0 KiB
Ruby
require_relative "test_helper"
|
|
require "maptool/gpx"
|
|
|
|
class GpxTest < Minitest::Test
|
|
def test_kinds_detects_waypoints_and_tracks
|
|
assert_equal %w[waypoint track], Maptool::Gpx.kinds(fixture_path("sample.gpx"))
|
|
end
|
|
|
|
def test_to_geojson_produces_a_waypoint_point_feature
|
|
geojson = Maptool::Gpx.to_geojson(fixture_path("sample.gpx"))
|
|
waypoint = geojson["features"].find { |f| f["properties"]["kind"] == "waypoint" }
|
|
|
|
assert_equal "Point", waypoint["geometry"]["type"]
|
|
assert_equal [17.2, 48.1, 200.0], waypoint["geometry"]["coordinates"]
|
|
assert_equal "Start", waypoint["properties"]["name"]
|
|
assert_includes waypoint["properties"]["description"], "example.com"
|
|
end
|
|
|
|
def test_to_geojson_produces_a_track_multilinestring_with_stats
|
|
geojson = Maptool::Gpx.to_geojson(fixture_path("sample.gpx"))
|
|
track = geojson["features"].find { |f| f["properties"]["kind"] == "track" }
|
|
|
|
assert_equal "MultiLineString", track["geometry"]["type"]
|
|
assert_equal 3, track["geometry"]["coordinates"][0].size
|
|
assert_equal "Morning Loop", track["properties"]["name"]
|
|
assert_equal "running", track["properties"]["activity_type"]
|
|
assert_operator track["properties"]["distance_meters"], :>, 0
|
|
assert_equal 50.0, track["properties"]["elevation_gain_meters"], "200->250 is the only gain, 250->230 descent not subtracted"
|
|
assert_equal 1200, track["properties"]["duration_secs"]
|
|
end
|
|
|
|
def test_track_profile_carries_extension_fields_and_tolerates_points_without_them
|
|
geojson = Maptool::Gpx.to_geojson(fixture_path("sample.gpx"))
|
|
profile = geojson["features"].find { |f| f["properties"]["kind"] == "track" }["properties"]["profile"]
|
|
|
|
assert_equal 3, profile.size
|
|
assert_equal 140, profile[0]["heart_rate_bpm"]
|
|
assert_equal 84, profile[1]["cadence_rpm"]
|
|
assert_nil profile[2]["heart_rate_bpm"], "third point has no <extensions>"
|
|
assert_equal 0.0, profile[0]["distance_meters"]
|
|
assert_equal [17.2, 48.1], [profile[0]["lon"], profile[0]["lat"]]
|
|
end
|
|
|
|
def test_waypoint_captures_category_and_color_from_poi_extensions
|
|
geojson = Maptool::Gpx.to_geojson(fixture_path("sample_poi.gpx"))
|
|
free_spot = geojson["features"].find { |f| f["properties"]["name"] == "Free spot" }
|
|
|
|
assert_equal "Free motorhome area", free_spot["properties"]["category"]
|
|
assert_equal "#2E7D32", free_spot["properties"]["color"]
|
|
assert_equal "tourism_camp_site", free_spot["properties"]["icon"]
|
|
end
|
|
|
|
# Regression test for a real bug: a p4n2gpx export's <extensions> carries
|
|
# both an <osmand:icon> (a category key this app can render) and a
|
|
# <locus:icon> (a Locus-app drawable path) side by side. REXML strips
|
|
# namespace prefixes from Element#name, so both elements report the same
|
|
# local name "icon" — matching on that alone picked whichever happened to
|
|
# come last in the file (locus, since it followed osmand), leaving every
|
|
# waypoint's icon set to a meaningless drawable path that never matched
|
|
# any category. Must match by namespace URI instead.
|
|
def test_waypoint_icon_ignores_non_osmand_extensions_with_the_same_local_name
|
|
geojson = Maptool::Gpx.to_geojson(fixture_path("sample_poi.gpx"))
|
|
export = geojson["features"].find { |f| f["properties"]["name"] == "p4n2gpx export" }
|
|
|
|
assert_equal "amenity_parking", export["properties"]["icon"]
|
|
end
|
|
|
|
def test_waypoint_without_category_or_color_leaves_them_nil
|
|
geojson = Maptool::Gpx.to_geojson(fixture_path("sample_poi.gpx"))
|
|
plain = geojson["features"].find { |f| f["properties"]["name"] == "Plain waypoint" }
|
|
|
|
assert_nil plain["properties"]["category"]
|
|
assert_nil plain["properties"]["color"]
|
|
assert_nil plain["properties"]["icon"]
|
|
end
|
|
|
|
def test_kinds_detects_routes
|
|
assert_equal ["route"], Maptool::Gpx.kinds(fixture_path("sample_route.gpx"))
|
|
end
|
|
|
|
def test_to_geojson_produces_a_route_multilinestring
|
|
geojson = Maptool::Gpx.to_geojson(fixture_path("sample_route.gpx"))
|
|
route = geojson["features"].find { |f| f["properties"]["kind"] == "route" }
|
|
|
|
assert_equal "MultiLineString", route["geometry"]["type"]
|
|
assert_equal 2, route["geometry"]["coordinates"][0].size
|
|
assert_equal "Planned Loop", route["properties"]["name"]
|
|
assert_nil route["properties"]["activity_type"], "<rte> has no GPX equivalent of <trk><type>"
|
|
assert_operator route["properties"]["distance_meters"], :>, 0
|
|
end
|
|
|
|
# Regression test for a real bug: point_extensions originally used the
|
|
# "extensions//*" XPath (REXML's `//` descendant axis), which cost
|
|
# milliseconds *per call* regardless of subtree size — fine on this
|
|
# suite's small fixtures, ~30s on a real 8000-point file. This fixture is
|
|
# smaller than that (keeping the suite fast) but big enough that an
|
|
# O(n^2) reintroduction would make itself obvious well under the bound.
|
|
def test_parsing_many_points_with_extensions_stays_fast
|
|
points = Array.new(2000) do |i|
|
|
<<~TRKPT
|
|
<trkpt lat="48.1" lon="17.2"><ele>200</ele><time>2026-01-01T09:00:#{format("%02d", i % 60)}Z</time>
|
|
<extensions><gpxtpx:TrackPointExtension xmlns:gpxtpx="urn"><gpxtpx:hr>140</gpxtpx:hr></gpxtpx:TrackPointExtension></extensions>
|
|
</trkpt>
|
|
TRKPT
|
|
end.join
|
|
path = File.join(Dir.mktmpdir, "big.gpx")
|
|
File.write(path, "<gpx><trk><trkseg>#{points}</trkseg></trk></gpx>")
|
|
|
|
started = Time.now
|
|
geojson = Maptool::Gpx.to_geojson(path)
|
|
elapsed = Time.now - started
|
|
|
|
assert_equal 2000, geojson["features"][0]["geometry"]["coordinates"][0].size
|
|
assert_operator elapsed, :<, 5, "took #{elapsed}s for 2000 points — was O(n^2) via an XPath descendant search once, see comment above"
|
|
end
|
|
|
|
def test_invalid_xml_raises_parse_error
|
|
path = File.join(Dir.mktmpdir, "bad.gpx")
|
|
File.write(path, "<gpx><trk unclosed>")
|
|
|
|
assert_raises(Maptool::Gpx::ParseError) { Maptool::Gpx.to_geojson(path) }
|
|
end
|
|
|
|
def test_non_gpx_root_raises_parse_error
|
|
path = File.join(Dir.mktmpdir, "notgpx.gpx")
|
|
File.write(path, "<html></html>")
|
|
|
|
assert_raises(Maptool::Gpx::ParseError) { Maptool::Gpx.to_geojson(path) }
|
|
end
|
|
end
|