diff --git a/lib/maptool/gpx_store.rb b/lib/maptool/gpx_store.rb index b319e71..80527d3 100644 --- a/lib/maptool/gpx_store.rb +++ b/lib/maptool/gpx_store.rb @@ -22,7 +22,10 @@ module Maptool end # Nested { type:, name:, path:, children: } hash describing every - # folder and .gpx/.fit file under the store root. + # folder and .gpx/.fit file under the store root. Each file also + # carries "cached" — whether its GeoJSON conversion is ready on disk + # (see `geojson` below) — so the tree UI can show a file's ready to + # view instantly, without a round trip just to find out. def tree { "type" => "folder", "name" => "root", "path" => "", "children" => list_children(@root, "") } end @@ -112,7 +115,7 @@ module Maptool raise Errno::ENOENT, path unless File.file?(path) cache = geojson_cache_path(path) - if !File.file?(cache) || File.mtime(cache) < File.mtime(path) + unless geojson_cached?(path) data = path.downcase.end_with?(".fit") ? Fit.to_geojson(path) : Gpx.to_geojson(path) File.write(cache, JSON.generate(data)) end @@ -136,6 +139,11 @@ module Maptool "#{path}.geojson" end + def geojson_cached?(path) + cache = geojson_cache_path(path) + File.file?(cache) && File.mtime(cache) >= File.mtime(path) + end + TRACK_EXTENSIONS = %w[.gpx .fit].freeze def safe_filename(filename) @@ -165,9 +173,9 @@ module Maptool if File.directory?(full) { "type" => "folder", "name" => entry, "path" => rel, "children" => list_children(full, rel) } elsif entry.downcase.end_with?(".gpx") - { "type" => "file", "name" => entry, "path" => rel, "kinds" => Gpx.kinds(full) } + { "type" => "file", "name" => entry, "path" => rel, "kinds" => Gpx.kinds(full), "cached" => geojson_cached?(full) } elsif entry.downcase.end_with?(".fit") - { "type" => "file", "name" => entry, "path" => rel, "kinds" => Fit.kinds(full) } + { "type" => "file", "name" => entry, "path" => rel, "kinds" => Fit.kinds(full), "cached" => geojson_cached?(full) } end end end diff --git a/public/js/gpx.js b/public/js/gpx.js index 40c5292..5bc2109 100644 --- a/public/js/gpx.js +++ b/public/js/gpx.js @@ -6,6 +6,7 @@ import { WAYPOINT_ICON, TRACK_ICON, ROUTE_ICON, + GEOJSON_ICON, MAP_MARKER_ICON, POI_ICONS, PARK4NIGHT_TAGS, @@ -276,6 +277,7 @@ export class GpxPanelControl { name.title = file.name; label.appendChild(checkbox); (file.kinds || []).forEach((kind) => label.appendChild(this._kindIcon(kind))); + if (file.cached) label.appendChild(this._cachedIcon()); label.appendChild(name); row.appendChild(label); row.appendChild(this._deleteButton(() => this._deleteFile(file.path))); @@ -299,6 +301,14 @@ export class GpxPanelControl { return icon; } + _cachedIcon() { + const icon = document.createElement("span"); + icon.className = "gpx-tree__kind-icon"; + icon.innerHTML = GEOJSON_ICON; + icon.title = "Ready to view instantly (GeoJSON cached)"; + return icon; + } + _deleteButton(onClick) { const button = document.createElement("button"); button.type = "button"; diff --git a/public/js/icons.js b/public/js/icons.js index 3258a70..b7d8d98 100644 --- a/public/js/icons.js +++ b/public/js/icons.js @@ -54,6 +54,16 @@ export const WAYPOINT_ICON = ` export const TRACK_ICON = GPX_ICON; +// Shown next to a file whose GeoJSON conversion is already cached on disk +// (GpxStore#geojson) — ready to view instantly instead of paying REXML's +// parse cost on click. Curly braces read as "structured data ready", the +// same shorthand most editors/tools use for JSON. +export const GEOJSON_ICON = ` + + + +`; + // Same shape as TRACK_ICON, dashed — a route is planned rather than // recorded, the same visual convention most map apps use for the // distinction. diff --git a/test/test_gpx_store.rb b/test/test_gpx_store.rb index 6724d8d..c6f714f 100644 --- a/test/test_gpx_store.rb +++ b/test/test_gpx_store.rb @@ -121,6 +121,30 @@ class GpxStoreTest < Minitest::Test assert_raises(Errno::ENOENT) { @store.geojson("nope.gpx") } end + def test_tree_reports_cached_true_after_a_successful_save + @store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx)) + + file = @store.tree["children"].find { |c| c["name"] == "ride.gpx" } + assert file["cached"] + end + + def test_tree_reports_cached_false_for_an_unparseable_file + @store.save(folder: "", filename: "bad.gpx", io: StringIO.new("not gpx at all")) + + file = @store.tree["children"].find { |c| c["name"] == "bad.gpx" } + refute file["cached"] + end + + def test_tree_reports_cached_false_once_the_source_is_resaved + path = @store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx)) + FileUtils.touch(@store.resolve_file(path) + ".geojson", mtime: Time.now - 10) + File.write(@store.resolve_file(path), sample_gpx) + FileUtils.touch(@store.resolve_file(path), mtime: Time.now) + + file = @store.tree["children"].find { |c| c["name"] == "ride.gpx" } + refute file["cached"] + end + private def sample_gpx