From 0b18dc018a195a7a0e6eb87cebf1bf65222f3a3b Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Mon, 7 Sep 2026 16:18:06 +0200 Subject: [PATCH] Color-code, cluster and add icons to POI waypoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 and 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. --- docs/architecture.adoc | 70 +++++++++-- lib/maptool/gpx.rb | 34 ++++++ public/css/style.css | 33 +++++ public/js/gpx.js | 228 +++++++++++++++++++++++++++++++---- public/js/icons.js | 47 ++++++++ test/fixtures/sample_poi.gpx | 23 ++++ test/test_gpx.rb | 33 +++++ views/index.erb | 1 + 8 files changed, 434 insertions(+), 35 deletions(-) create mode 100644 test/fixtures/sample_poi.gpx diff --git a/docs/architecture.adoc b/docs/architecture.adoc index 57ac2df..ea35f45 100644 --- a/docs/architecture.adoc +++ b/docs/architecture.adoc @@ -193,17 +193,63 @@ route icon next to a route file and a solid one for a track `Fit` reads Garmin's binary FIT format directly (hand-rolled — see below) into a single `MultiLineString` `track` feature; FIT activity files don't really have a GPX-waypoint -equivalent, so it never produces `Point` features. MapLibre's -`geometry-type` expression treats `MultiLineString` the same as -`LineString`, so a single GeoJSON source per file with a `line` layer -and a `circle` layer (each filtered by geometry type) is enough to -render both. Checking a file fetches its GeoJSON itself -(`public/js/gpx.js`'s `_addLayers`) and hands the parsed result to -`addSource` — rather than passing MapLibre a URL and letting it fetch -internally — specifically so a `422` (a file REXML or `Fit` can't -parse) surfaces as an alert and un-checks the box instead of the layer -just silently never appearing, which is what a `data: url` source does -on a failed fetch. +equivalent, so it never produces `Point` features. Checking a file +fetches its GeoJSON itself (`public/js/gpx.js`'s `_addLayers`) and +hands the parsed result to `addSource` — rather than passing MapLibre a +URL and letting it fetch internally — specifically so a `422` (a file +REXML or `Fit` can't parse) surfaces as an alert and un-checks the box +instead of the layer just silently never appearing, which is what a +`data: url` source does on a failed fetch. The fetched features are +then split client-side into two GeoJSON sources per file, a +lines-and-routes one and a points-only one, rather than one mixed +source with a `geometry-type` filter per layer (the previous design): +MapLibre's built-in point clustering (below) only understands a source +made entirely of `Point`/`MultiPoint` features, so a file mixing a +track with waypoints — or a several-thousand-waypoint POI export with +no line at all — needs the points on their own source regardless of +what else the file's GeoJSON also carries. + +POI category styling & clustering:: A POI export (park4night via +p4n2gpx, OsmAnd) tags each waypoint with a category (``) and, in +a vendor `` block, a per-category color and OsmAnd icon key +(e.g. `amenity_parking`) — `Maptool::Gpx.waypoint_style` reads both +into `category`/`color` properties (an OsmAnd export can carry a +`` alongside ``, both of which strip down to +the same local element name "icon" once REXML drops namespace +prefixes, so this specifically matches on namespace *URI* +(`https://osmand.net`), not local name, to avoid picking whichever +happens to come last in the file). The unclustered-points map layer +colors each dot via `["coalesce", ["get", "color"], TRACK_COLOR]` +(falling back to the plain track color for a waypoint with no +category, e.g. a hand-placed GNSS pin), and a small curated icon set +(`public/js/icons.js`'s `POI_ICONS`, ~7 hand-drawn glyphs covering the +categories p4n2gpx actually uses, not an exhaustive OsmAnd icon set) is +rasterized once per style load (`_registerPoiIcons`, an SVG → +`` → `` → `ImageData` round trip, since these need to be +real raster images for `map.addImage`) and registered as SDF images — +a single alpha shape MapLibre tints at render time via `icon-color` +rather than a fixed-color image — so a symbol layer can draw a white +glyph on top of each dot for any waypoint whose `icon` matches a known +key (filtered out entirely for anything else, so an unrecognized icon +key just falls back to a plain colored dot rather than a broken/missing +image warning). The points source itself is created with +`cluster: true`; nearby points collapse into a stepped-radius, +sequential-blue circle sized by `point_count` below `clusterMaxZoom`, +clicking one zooms to its `getClusterExpansionZoom()`, and this is +harmless to enable unconditionally — a source with nothing dense enough +to cluster just renders every point individually as usual. A small +legend (`.gpx-legend` in the side panel) lists every distinct category ++ color currently visible across all checked files, rebuilt from the +same points GeoJSON kept around for exactly this +(`GpxPanelControl#_pointsByPath`) whenever a file is toggled on/off; it +stays hidden for files with no categorized waypoints (a plain GPS +track has none). The legend swatch color is set via +`swatch.style.background = color` (a DOM property assignment) rather +than interpolating the untrusted color string into an `innerHTML` +attribute — `escapeHtml` (used elsewhere on this page) only guards +element content, not attribute-quote escaping, so building +`style="background:${color}"` as a string would have been an +injection risk for a crafted GPX file. FIT parsing:: No gem was added for this — the one Ruby option (`fit-parser`, tjwallace/fit) hasn't been touched since 2022 and pulls @@ -234,7 +280,7 @@ Like the radar overlay, GPX layers are removed by `map.setStyle()` whichever files were checked. Waypoint popups:: Clicking a waypoint circle opens a MapLibre popup -with its name, description and time. Click/hover handling is +with its name, category (if any), description and time. Click/hover handling is registered once per file (via `map.on(type, layerId, handler)`, a delegated listener that survives `setStyle()`) rather than on every `style.load` re-add, which would otherwise stack duplicate handlers diff --git a/lib/maptool/gpx.rb b/lib/maptool/gpx.rb index e948b1f..b203b90 100644 --- a/lib/maptool/gpx.rb +++ b/lib/maptool/gpx.rb @@ -39,6 +39,7 @@ module Maptool end def self.waypoint_feature(wpt) + ext = waypoint_style(wpt) { "type" => "Feature", "geometry" => { "type" => "Point", "coordinates" => coordinate(wpt) }, @@ -47,11 +48,44 @@ module Maptool "name" => child_text(wpt, "name"), "description" => child_text(wpt, "desc"), "time" => child_text(wpt, "time"), + "category" => child_text(wpt, "type"), + "color" => ext[:color], + "icon" => ext[:icon], }, } end private_class_method :waypoint_feature + OSMAND_NAMESPACE = "https://osmand.net" + private_constant :OSMAND_NAMESPACE + + # POI exports (park4night via p4n2gpx, OsmAnd) tag each waypoint with a + # category () and, in a vendor block, a per-category + # color and an OsmAnd icon key (e.g. "amenity_parking") — used to + # color/icon-code the map instead of every waypoint rendering as the same + # plain dot. Matched by namespace *URI*, not just local element name + # (unlike point_extensions below, which only cares about local names): + # a p4n2gpx export carries both an (a category key we can + # actually render) and a (a Locus-app-specific drawable + # path) side by side — both strip down to the same local name "icon", + # so matching on that alone picks whichever happens to come last. + def self.waypoint_style(wpt) + values = {} + extensions = wpt.elements["extensions"] + return values unless extensions + + extensions.elements.each do |el| + next unless el.namespace == OSMAND_NAMESPACE + + case el.name + when "color" then values[:color] = el.text&.strip + when "icon" then values[:icon] = el.text&.strip + end + end + values + end + private_class_method :waypoint_style + def self.track_feature(trk) lines = [] all_points = [] diff --git a/public/css/style.css b/public/css/style.css index bd6203e..b914c49 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -398,6 +398,32 @@ html, body { outline-offset: -2px; } +.gpx-legend { + flex-shrink: 0; + max-height: 140px; + overflow-y: auto; + padding: 8px 12px; + border-top: 1px solid #eee; +} + +.gpx-legend[hidden] { + display: none; +} + +.gpx-legend__item { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 0; +} + +.gpx-legend__swatch { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + #map.gpx-drop-hover::after { content: ""; position: absolute; @@ -417,6 +443,13 @@ html, body { font-size: 12px; } +.gpx-popup__category { + color: #888; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.02em; +} + .gnss-control__status { color: #888; white-space: nowrap; diff --git a/public/js/gpx.js b/public/js/gpx.js index a2529e8..56c8d58 100644 --- a/public/js/gpx.js +++ b/public/js/gpx.js @@ -1,5 +1,5 @@ import { Popup } from "/vendor/maplibre-gl/maplibre-gl.mjs"; -import { GPX_ICON, NEW_FOLDER_ICON, UPLOAD_ICON, WAYPOINT_ICON, TRACK_ICON, ROUTE_ICON } from "/js/icons.js"; +import { GPX_ICON, NEW_FOLDER_ICON, UPLOAD_ICON, WAYPOINT_ICON, TRACK_ICON, ROUTE_ICON, POI_ICONS } from "/js/icons.js"; import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; import { formatActivityType } from "/js/track_profile.js"; @@ -8,6 +8,9 @@ const UPLOAD_URL = "/api/gpx"; const FOLDER_URL = "/api/gpx/folder"; const MOVE_URL = "/api/gpx/move"; const TRACK_COLOR = "#e63946"; +const POI_ICON_KEYS = Object.keys(POI_ICONS); +const POI_ICON_IMAGE_SIZE = 48; // rasterized once at this size, rendered much smaller by icon-size below +const POI_ICON_SIZE = 0.3; // 48 * 0.3 = ~14px, matching the unclustered circle's 14px diameter // Custom drag payload MIME type for moving an already-uploaded file between // folders, as opposed to an OS file drag (type "Files") for uploading a new @@ -24,14 +27,33 @@ export class GpxPanelControl { this._profilePanel = profilePanel; this._visible = new Set(); // file paths currently shown on the map this._interactiveLayers = new Set(); // point layer ids with click/hover wired up + this._pointsByPath = new Map(); // path -> its waypoints FeatureCollection, for the category legend // setStyle() (switching the Online/Offline base map) tears down all - // custom sources and layers — put visible GPX layers back afterwards. + // custom sources/layers/images — re-register the POI icon sprites + // before putting visible GPX layers back, so a symbol layer added right + // away doesn't reference a not-yet-existing image. + this._iconsReady = this._registerPoiIcons(); map.on("style.load", () => { + this._iconsReady = this._registerPoiIcons(); this._visible.forEach((path) => this._addLayers(path)); }); } + // SDF images (a single alpha shape, tinted at render time via icon-color) + // rather than plain color images: the source SVGs are silhouettes with no + // color information worth preserving, and SDF lets the same sprite be + // reused if a future category needs a different tint. + async _registerPoiIcons() { + await Promise.all( + POI_ICON_KEYS.map(async (key) => { + if (this._map.hasImage(key)) return; + const imageData = await svgToImageData(POI_ICONS[key], POI_ICON_IMAGE_SIZE); + if (!this._map.hasImage(key)) this._map.addImage(key, imageData, { sdf: true }); + }), + ); + } + mount(sidebar) { this._container = document.createElement("div"); this._container.className = "sidebar-item"; @@ -41,6 +63,7 @@ export class GpxPanelControl { this._panel = document.getElementById("gpx-panel"); this._treeContainer = this._panel.querySelector(".gpx-tree"); + this._legendContainer = this._panel.querySelector(".gpx-legend"); this._uploadStatus = this._panel.querySelector(".gpx-upload__status"); this._panel.querySelector('[data-action="new-folder"]').innerHTML = NEW_FOLDER_ICON; this._panel.querySelector('[data-action="upload"]').innerHTML = UPLOAD_ICON; @@ -294,8 +317,9 @@ export class GpxPanelControl { // instead of the layer just silently never appearing. Returns whether it // succeeded. async _addLayers(path) { - const id = sourceId(path); - if (this._map.getSource(id)) return true; + const linesId = linesSourceId(path); + const pointsId = pointsSourceId(path); + if (this._map.getSource(linesId) || this._map.getSource(pointsId)) return true; let geojson; try { @@ -311,33 +335,120 @@ export class GpxPanelControl { return false; } - if (this._map.getSource(id)) return true; // added concurrently (e.g. a style.load race) while this was fetching + if (this._map.getSource(linesId) || this._map.getSource(pointsId)) return true; // added concurrently (e.g. a style.load race) while this was fetching - this._map.addSource(id, { type: "geojson", data: geojson }); + // Split into two sources: MapLibre's clustering only understands a + // source made entirely of Point/MultiPoint features, and a file can mix + // a track/route line with waypoints (or, like a park4night POI export, + // be thousands of waypoints and nothing else). + const lines = { type: "FeatureCollection", features: [] }; + const points = { type: "FeatureCollection", features: [] }; + for (const feature of geojson.features) { + (feature.geometry.type === "Point" ? points : lines).features.push(feature); + } + + this._map.addSource(linesId, { type: "geojson", data: lines }); this._map.addLayer({ - id: `${id}-line`, + id: `${linesId}-line`, type: "line", - source: id, - filter: ["==", ["geometry-type"], "LineString"], + source: linesId, layout: { "line-cap": "round", "line-join": "round" }, paint: { "line-color": TRACK_COLOR, "line-width": 3 }, }); + this._registerLineInteractivity(`${linesId}-line`); + + // clusterRadius/clusterMaxZoom are harmless when there's nothing dense + // enough to cluster (a handful of waypoints just render individually). + this._map.addSource(pointsId, { type: "geojson", data: points, cluster: true, clusterRadius: 50, clusterMaxZoom: 14 }); + await this._iconsReady; + if (!this._map.getSource(pointsId)) return true; // a style.load (basemap switch) tore it down while icons were loading + + this._addClusterLayers(pointsId); + + if (points.features.length > 0) this._pointsByPath.set(path, points); + this._updateLegend(); + + return true; + } + + // A single-hue, light->dark ramp keyed on point_count (magnitude, not + // category) for the clustered circles; individual points below that use + // each waypoint's own category color (see Maptool::Gpx#waypoint_color), + // falling back to the plain track color for waypoints with no category + // (e.g. a hand-placed GNSS pin, not from a POI export). + _addClusterLayers(pointsId) { this._map.addLayer({ - id: `${id}-points`, + id: `${pointsId}-clusters`, type: "circle", - source: id, - filter: ["==", ["geometry-type"], "Point"], + source: pointsId, + filter: ["has", "point_count"], paint: { - "circle-radius": 5, - "circle-color": TRACK_COLOR, + "circle-radius": ["step", ["get", "point_count"], 16, 25, 20, 100, 26], + "circle-color": ["step", ["get", "point_count"], "#a8c8ea", 25, "#5b9bd5", 100, "#2e6da4"], "circle-stroke-color": "#fff", "circle-stroke-width": 1.5, }, }); + this._map.addLayer({ + id: `${pointsId}-cluster-count`, + type: "symbol", + source: pointsId, + filter: ["has", "point_count"], + layout: { "text-field": ["get", "point_count_abbreviated"], "text-font": ["Noto Sans Regular"], "text-size": 11 }, + paint: { "text-color": "#fff" }, + }); + this._map.addLayer({ + id: `${pointsId}-unclustered`, + type: "circle", + source: pointsId, + filter: ["!", ["has", "point_count"]], + paint: { + "circle-radius": 7, + "circle-color": ["coalesce", ["get", "color"], TRACK_COLOR], + "circle-stroke-color": "#fff", + "circle-stroke-width": 1.5, + }, + }); + // Only for the curated POI_ICON_KEYS (see icons.js) — anything else + // (a plain hand-placed waypoint, or a category p4n2gpx/OsmAnd hasn't + // been taught here) just keeps the colored dot above with no icon. + this._map.addLayer({ + id: `${pointsId}-unclustered-icon`, + type: "symbol", + source: pointsId, + filter: ["all", ["!", ["has", "point_count"]], ["in", ["get", "icon"], ["literal", POI_ICON_KEYS]]], + layout: { + "icon-image": ["get", "icon"], + "icon-size": POI_ICON_SIZE, + "icon-allow-overlap": true, + "icon-ignore-placement": true, + }, + paint: { "icon-color": "#fff" }, + }); - this._registerPointInteractivity(`${id}-points`); - this._registerLineInteractivity(`${id}-line`); - return true; + this._registerClusterInteractivity(pointsId); + this._registerPointInteractivity(`${pointsId}-unclustered`); + } + + // Clicking a cluster zooms in just far enough to break it apart, the + // standard MapLibre cluster-expansion pattern. + _registerClusterInteractivity(pointsId) { + const clusterId = `${pointsId}-clusters`; + if (this._interactiveLayers.has(clusterId)) return; + this._interactiveLayers.add(clusterId); + + this._map.on("click", clusterId, async (event) => { + const feature = this._map.queryRenderedFeatures(event.point, { layers: [clusterId] })[0]; + if (!feature) return; + const zoom = await this._map.getSource(pointsId).getClusterExpansionZoom(feature.properties.cluster_id); + this._map.easeTo({ center: feature.geometry.coordinates, zoom }); + }); + this._map.on("mouseenter", clusterId, () => { + this._map.getCanvas().style.cursor = "pointer"; + }); + this._map.on("mouseleave", clusterId, () => { + this._map.getCanvas().style.cursor = ""; + }); } // Registered once per layer id, ever — map.on(type, layerId, fn) is a @@ -396,11 +507,50 @@ export class GpxPanelControl { } _removeLayers(path) { - const id = sourceId(path); - [`${id}-line`, `${id}-points`].forEach((layerId) => { + const linesId = linesSourceId(path); + const pointsId = pointsSourceId(path); + [ + `${linesId}-line`, + `${pointsId}-clusters`, + `${pointsId}-cluster-count`, + `${pointsId}-unclustered`, + `${pointsId}-unclustered-icon`, + ].forEach((layerId) => { if (this._map.getLayer(layerId)) this._map.removeLayer(layerId); }); - if (this._map.getSource(id)) this._map.removeSource(id); + if (this._map.getSource(linesId)) this._map.removeSource(linesId); + if (this._map.getSource(pointsId)) this._map.removeSource(pointsId); + + this._pointsByPath.delete(path); + this._updateLegend(); + } + + // Rebuilt from whichever visible files actually carry categorized + // waypoints (a park4night/OsmAnd POI export) — a plain GPS track has none + // and the legend just stays hidden. + _updateLegend() { + const categories = new Map(); // category name -> color + for (const points of this._pointsByPath.values()) { + for (const feature of points.features) { + const category = feature.properties.category; + if (category && !categories.has(category)) categories.set(category, feature.properties.color || TRACK_COLOR); + } + } + + this._legendContainer.innerHTML = ""; + this._legendContainer.hidden = categories.size === 0; + [...categories].sort(([a], [b]) => a.localeCompare(b)).forEach(([category, color]) => { + const item = document.createElement("div"); + item.className = "gpx-legend__item"; + const swatch = document.createElement("span"); + swatch.className = "gpx-legend__swatch"; + swatch.style.background = color; // a malformed/malicious value from an uploaded file is simply ignored by the CSSOM, never parsed as markup + const label = document.createElement("span"); + label.textContent = category; + item.appendChild(swatch); + item.appendChild(label); + this._legendContainer.appendChild(item); + }); } async _uploadFiles(fileList, folder) { @@ -504,8 +654,39 @@ function redirectIfUnauthorized(response) { return true; } -function sourceId(path) { - return `gpx-${path}`; +// Rasterizes a hand-authored SVG icon to ImageData for map.addImage(), at a +// fixed square size (the source SVGs have no explicit width/height, just a +// viewBox, so one is injected — otherwise an falls back to the CSS +// default replaced-element size instead of the viewBox's aspect). +async function svgToImageData(svgMarkup, size) { + // xmlns is required here even though the other hand-drawn icons in this + // app get away without it — those are inlined via innerHTML, where the + // HTML parser assigns the SVG namespace from context, but a standalone + // loads it as its own XML document, which needs an + // explicit namespace or every browser refuses to decode it. + const sized = svgMarkup.replace("${escapeHtml(name)}`); + if (category) parts.push(`

${escapeHtml(category)}

`); if (description) parts.push(`

${linkify(description).replace(/\n/g, "
")}

`); if (time) { const parsed = new Date(time); diff --git a/public/js/icons.js b/public/js/icons.js index a0e10a3..50e40f8 100644 --- a/public/js/icons.js +++ b/public/js/icons.js @@ -58,6 +58,53 @@ export const ROUTE_ICON = ` `; +// POI category glyphs (park4night/OsmAnd exports), keyed by the OsmAnd icon +// name a file's extension carries — a small curated set for +// the categories park4night actually uses (see waypoint_style in +// lib/maptool/gpx.rb), not an exhaustive OsmAnd icon set. Filled shapes +// (not stroked outlines like the sidebar icons above) since these render at +// ~14px on the map, where a hairline stroke disappears; solid silhouettes +// stay legible that small. Rasterized and used as SDF map icons (see +// gpx.js), so only the alpha shape matters — the actual `currentColor` +// value is irrelevant. +export const POI_ICONS = { + amenity_parking: ` +P`, + + tourism_camp_site: ` +`, + + leisure_picnic_table: ` + + + +`, + + amenity_fuel: ` +`, + + tourism_hotel: ` + + + + + +`, + + forest: ` + + + +`, + + sport_cycling: ` + + + + +`, +}; + export const LIVETRACK_ICON = ` diff --git a/test/fixtures/sample_poi.gpx b/test/fixtures/sample_poi.gpx new file mode 100644 index 0000000..2c93f3b --- /dev/null +++ b/test/fixtures/sample_poi.gpx @@ -0,0 +1,23 @@ + + + + Free spot + Free motorhome area + + #2E7D32 + tourism_camp_site + + + + Plain waypoint + + + p4n2gpx export + Daily parking only + + #546E7A + amenity_parking + res/drawable-hdpi/ic_parking_small.png + + + diff --git a/test/test_gpx.rb b/test/test_gpx.rb index 05442a0..f2afd66 100644 --- a/test/test_gpx.rb +++ b/test/test_gpx.rb @@ -41,6 +41,39 @@ class GpxTest < Minitest::Test 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 carries + # both an (a category key this app can render) and a + # (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 diff --git a/views/index.erb b/views/index.erb index 8483197..cfaee76 100644 --- a/views/index.erb +++ b/views/index.erb @@ -57,6 +57,7 @@
+