Cache parsed GPX/FIT GeoJSON to fix large POI-export loads
CI / check (push) Successful in 42s
CI / deploy (push) Successful in 14s

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:
randy committed 2026-09-07 22:21:00 +02:00
1 parent b6a9c1546e
commit ddf2ee4990
4 files changed
+94 -9

No files matched your search

+6 -7
View File
@@ -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
+6 -1
View File
@@ -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>`
+27 -1
View File
@@ -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)
+55
View File
@@ -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