Cache parsed GPX/FIT GeoJSON to fix large POI-export loads
A several-thousand-waypoint park4night area export re-parses the whole file with REXML on every toggle — ~2.4s for a 5k-waypoint/3.6MB sample, so tens of seconds for a 50k-waypoint one, likely the actual cause behind a "Could not load" report on a 41.5MB export. MapLibre already clusters the waypoint layer client-side, so the bottleneck was never rendering — cache the GeoJSON conversion alongside the source file (invalidated by mtime) instead of a bigger bbox/tiling rework.
This commit is contained in:
1 parent
b6a9c1546e
commit
ddf2ee4990
4 files changed
+94
-9
No files matched your search
@@ -193,18 +193,17 @@ module Maptool
|
||||
|
||||
get "/api/gpx/file/*" do
|
||||
begin
|
||||
path = GPX_STORE.resolve_file(params[:splat].first)
|
||||
body = GPX_STORE.geojson(params[:splat].first)
|
||||
rescue GpxStore::InvalidPath
|
||||
halt 400
|
||||
end
|
||||
halt 404 unless File.file?(path)
|
||||
|
||||
begin
|
||||
geojson = path.downcase.end_with?(".fit") ? Fit.to_geojson(path) : Gpx.to_geojson(path)
|
||||
json geojson
|
||||
rescue Errno::ENOENT
|
||||
halt 404
|
||||
rescue Gpx::ParseError, Fit::ParseError
|
||||
halt 422, json(error: "could not parse file")
|
||||
end
|
||||
|
||||
content_type :json
|
||||
body
|
||||
end
|
||||
|
||||
post "/api/gpx/folder" do
|
||||
|
||||
@@ -251,7 +251,12 @@ Rendering:: Checking a file fetches `GET /api/gpx/file/<path>`, routed
|
||||
by extension to `Maptool::Gpx` or `Maptool::Fit`
|
||||
(`lib/maptool/gpx.rb`/`fit.rb`) for parsing, and both convert to the
|
||||
same GeoJSON `FeatureCollection` shape so the frontend doesn't need to
|
||||
know which format it got. `Gpx` parses the GPX with REXML: `<wpt>`
|
||||
know which format it got. `GpxStore#geojson` caches that conversion
|
||||
alongside the source file (`<path>.geojson`, invalidated by mtime) —
|
||||
REXML parsing a several-thousand-waypoint POI export (a park4night
|
||||
area export, say) can take tens of seconds, and without a cache that
|
||||
cost repeats on every single toggle rather than just the first one
|
||||
after a save. `Gpx` parses the GPX with REXML: `<wpt>`
|
||||
waypoints become `Point` features (with `name`, `desc` and `time`
|
||||
carried through as properties), each `<trk>` becomes one
|
||||
`MultiLineString` feature (one line per `<trkseg>`), and each `<rte>`
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
require "fileutils"
|
||||
require "json"
|
||||
require_relative "gpx"
|
||||
require_relative "fit"
|
||||
|
||||
@@ -58,6 +59,7 @@ module Maptool
|
||||
raise Errno::ENOENT, path unless File.file?(path)
|
||||
|
||||
File.delete(path)
|
||||
File.delete(geojson_cache_path(path)) if File.file?(geojson_cache_path(path))
|
||||
end
|
||||
|
||||
def delete_folder(relative_path)
|
||||
@@ -79,10 +81,30 @@ module Maptool
|
||||
dest_dir = resolve_dir(to_folder.to_s)
|
||||
FileUtils.mkdir_p(dest_dir)
|
||||
name = unique_filename(dest_dir, File.basename(source))
|
||||
FileUtils.mv(source, File.join(dest_dir, name))
|
||||
dest = File.join(dest_dir, name)
|
||||
FileUtils.mv(source, dest)
|
||||
FileUtils.mv(geojson_cache_path(source), geojson_cache_path(dest)) if File.file?(geojson_cache_path(source))
|
||||
to_folder.to_s.empty? ? name : "#{to_folder}/#{name}"
|
||||
end
|
||||
|
||||
# The GeoJSON conversion (Gpx.to_geojson/Fit.to_geojson) re-parses the
|
||||
# whole file with REXML every call — fine for a small track, but a
|
||||
# several-thousand-waypoint POI export (e.g. a park4night area export)
|
||||
# can take tens of seconds, every single time the file is toggled
|
||||
# visible. Cached alongside the source file and invalidated by mtime,
|
||||
# so only the first read after a save (or edit) pays that cost.
|
||||
def geojson(relative_path)
|
||||
path = resolve_file(relative_path)
|
||||
raise Errno::ENOENT, path unless File.file?(path)
|
||||
|
||||
cache = geojson_cache_path(path)
|
||||
if !File.file?(cache) || File.mtime(cache) < File.mtime(path)
|
||||
data = path.downcase.end_with?(".fit") ? Fit.to_geojson(path) : Gpx.to_geojson(path)
|
||||
File.write(cache, JSON.generate(data))
|
||||
end
|
||||
File.read(cache)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve_dir(folder)
|
||||
@@ -96,6 +118,10 @@ module Maptool
|
||||
path == @root || path.start_with?(@root + File::SEPARATOR)
|
||||
end
|
||||
|
||||
def geojson_cache_path(path)
|
||||
"#{path}.geojson"
|
||||
end
|
||||
|
||||
TRACK_EXTENSIONS = %w[.gpx .fit].freeze
|
||||
|
||||
def safe_filename(filename)
|
||||
|
||||
@@ -67,4 +67,59 @@ class GpxStoreTest < Minitest::Test
|
||||
assert_equal "archive/ride-1.gpx", new_path
|
||||
assert_empty @store.tree["children"].reject { |c| c["name"] == "archive" }
|
||||
end
|
||||
|
||||
def test_geojson_is_cached_alongside_the_source_file
|
||||
path = @store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx))
|
||||
cache_path = @store.resolve_file(path) + ".geojson"
|
||||
refute File.file?(cache_path)
|
||||
|
||||
first = @store.geojson(path)
|
||||
assert File.file?(cache_path)
|
||||
|
||||
# Corrupt the cache directly to prove the second call reused it instead
|
||||
# of re-parsing the (still-valid) source file.
|
||||
File.write(cache_path, first.sub("waypoint", "cached-waypoint"))
|
||||
assert_includes @store.geojson(path), "cached-waypoint"
|
||||
end
|
||||
|
||||
def test_geojson_cache_is_invalidated_when_the_file_is_resaved
|
||||
path = @store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx))
|
||||
@store.geojson(path)
|
||||
|
||||
File.write(@store.resolve_file(path), sample_gpx.sub("Waypoint", "Renamed"))
|
||||
FileUtils.touch(@store.resolve_file(path), mtime: Time.now + 1)
|
||||
|
||||
assert_includes @store.geojson(path), "Renamed"
|
||||
end
|
||||
|
||||
def test_delete_file_removes_the_geojson_cache_too
|
||||
path = @store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx))
|
||||
@store.geojson(path)
|
||||
cache_path = @store.resolve_file(path) + ".geojson"
|
||||
|
||||
@store.delete_file(path)
|
||||
|
||||
refute File.file?(cache_path)
|
||||
end
|
||||
|
||||
def test_move_relocates_the_geojson_cache_too
|
||||
path = @store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx))
|
||||
@store.geojson(path)
|
||||
|
||||
new_path = @store.move(from: path, to_folder: "archive")
|
||||
|
||||
assert File.file?(@store.resolve_file(new_path) + ".geojson")
|
||||
end
|
||||
|
||||
def test_geojson_raises_enoent_for_a_missing_file
|
||||
assert_raises(Errno::ENOENT) { @store.geojson("nope.gpx") }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sample_gpx
|
||||
<<~GPX
|
||||
<gpx><wpt lat="48.1" lon="17.1"><name>Waypoint</name></wpt></gpx>
|
||||
GPX
|
||||
end
|
||||
end
|
||||
Reference in new issue
Block a user